feat:碎片完善

This commit is contained in:
zhongyidian committed 2026-01-11 18:18:20 +08:00
1 parent ec7e8e2bd2
commit 046501705f
74 files changed
+1590 -160

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

@@ -126,8 +126,10 @@ class FlyToAnimationManagerClass {
// 使用 cloneNode 深度克隆,包括所有子元素 // 使用 cloneNode 深度克隆,包括所有子元素
const clonedElement = fromRef.current!.cloneNode(true) as HTMLElement; const clonedElement = fromRef.current!.cloneNode(true) as HTMLElement;
// 复制计算后的样式到克隆元素 // 复制计算后的样式到克隆元素,确保尺寸正确
const computedStyle = window.getComputedStyle(fromRef.current!); const computedStyle = window.getComputedStyle(fromRef.current!);
// 复制所有样式属性
const allStyles = Array.from(computedStyle); const allStyles = Array.from(computedStyle);
allStyles.forEach((prop) => { allStyles.forEach((prop) => {
const value = computedStyle.getPropertyValue(prop); const value = computedStyle.getPropertyValue(prop);
@@ -136,6 +138,15 @@ class FlyToAnimationManagerClass {
} }
}); });
// 确保克隆元素的尺寸与原始元素一致
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(只获取内部内容,不包含外层元素) // 获取克隆后的 HTML(只获取内部内容,不包含外层元素)
const elementHtml = clonedElement.innerHTML; const elementHtml = clonedElement.innerHTML;
+17 -18
View File
@@ -28,8 +28,8 @@ export interface ExhibitOverlayProps {
autoPlayDescription?: boolean; autoPlayDescription?: boolean;
/** 点击展示 */ /** 点击展示 */
onClick?: () => void; onClick?: () => void;
/** 是否禁用拖拽,默认 false。当为 true 时,禁用拖拽和缩放功能,确保 onClick 事件能正常触发 */ /** 打开回调 */
disableDrag?: boolean; onOpen?: () => void;
} }
type Point = { x: number; y: number }; type Point = { x: number; y: number };
@@ -50,7 +50,7 @@ const ExhibitOverlay = ({
description, description,
autoPlayDescription = false, autoPlayDescription = false,
onClick, onClick,
disableDrag = false, onOpen,
}: ExhibitOverlayProps) => { }: ExhibitOverlayProps) => {
const maskSrc = getStaticImage("components/蒙版遮罩"); const maskSrc = getStaticImage("components/蒙版遮罩");
const backSrc = getStaticImage("components/关闭"); const backSrc = getStaticImage("components/关闭");
@@ -139,6 +139,12 @@ const ExhibitOverlay = ({
applyTransformStyle(); applyTransformStyle();
}, [applyTransformStyle]); }, [applyTransformStyle]);
useEffect(() => {
if (visible) {
onOpen?.();
}
}, [visible, onOpen]);
// 关闭或切换时重置 // 关闭或切换时重置
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
@@ -242,9 +248,6 @@ const ExhibitOverlay = ({
// 指针按下:记录初始状态 // 指针按下:记录初始状态
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
// 如果禁用了拖拽,直接返回,不处理拖拽事件
if (disableDrag) return;
if (!containerRef.current || !viewElRef.current) return; if (!containerRef.current || !viewElRef.current) return;
try { try {
containerRef.current.setPointerCapture(e.pointerId); containerRef.current.setPointerCapture(e.pointerId);
@@ -280,7 +283,7 @@ const ExhibitOverlay = ({
} }
} }
}, },
[getDistance, disableDrag] [getDistance]
); );
/** /**
@@ -514,18 +517,14 @@ const ExhibitOverlay = ({
maxHeight: containerSize, maxHeight: containerSize,
width: "100%", width: "100%",
height: "100%", height: "100%",
cursor: disableDrag cursor: isDragging ? "grabbing" : "grab",
? "default"
: isDragging
? "grabbing"
: "grab",
}} }}
onPointerDown={disableDrag ? undefined : handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={disableDrag ? undefined : handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={disableDrag ? undefined : handlePointerUp} onPointerUp={handlePointerUp}
onPointerCancel={disableDrag ? undefined : handlePointerUp} onPointerCancel={handlePointerUp}
onWheel={disableDrag ? undefined : handleWheel} onWheel={handleWheel}
onDoubleClick={disableDrag ? undefined : handleDoubleClick} onDoubleClick={handleDoubleClick}
> >
<div <div
ref={viewElRef} ref={viewElRef}
+4
View File
@@ -89,3 +89,7 @@ export type {
LetterToHuangyanpeiProps, LetterToHuangyanpeiProps,
LetterToLuxunProps, LetterToLuxunProps,
} from "./letter"; } from "./letter";
// Puzzle components
export { PuzzleSystem, PuzzlePieceDisplay } from "./puzzle";
export type { PuzzleSystemProps, PuzzlePieceDisplayProps } from "./puzzle";
+227 -33
View File
@@ -1,9 +1,15 @@
import { Outlet, useMatches, useLocation } from "react-router-dom"; import { Outlet, useMatches, useLocation } from "react-router-dom";
import { useState, useEffect, useRef, useMemo } from "react"; 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 type { VideoRouteHandle } from "@/router/types";
import Video, { type VideoRef } from "@/components/video"; import Video, { type VideoRef } from "@/components/video";
import { VideoProvider } from "@/contexts/VideoContext"; 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 matches = useMatches();
const location = useLocation(); const location = useLocation();
const { puzzleBoxRef } = usePuzzleBox();
const { unlockPuzzle, isPuzzleUnlocked } = usePuzzleStore();
const [isVideoPlaying, setIsVideoPlaying] = useState(false); const [isVideoPlaying, setIsVideoPlaying] = useState(false);
const [isContentVisible, setIsContentVisible] = useState(false); const [isContentVisible, setIsContentVisible] = useState(false);
const [isVideoFadingOut, setIsVideoFadingOut] = useState(false); const [isVideoFadingOut, setIsVideoFadingOut] = useState(false);
const [isPuzzleSystemVisible, setIsPuzzleSystemVisible] = useState(false);
const videoRef = useRef<VideoRef>(null); const videoRef = useRef<VideoRef>(null);
const previousPathRef = useRef<string>(""); 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 videoSrc = useMemo(() => {
const currentMatch = matches[matches.length - 1]; const currentMatch = matches[matches.length - 1];
@@ -113,43 +249,101 @@ const ResidenceLayout = () => {
isVideoPlaying={isVideoPlaying} isVideoPlaying={isVideoPlaying}
isContentVisible={isContentVisible} isContentVisible={isContentVisible}
> >
<div className="relative w-full h-full"> <PuzzleAnimationProvider
{/* 内容层 - 始终渲染,根据状态控制显示 */} triggerPuzzleAnimation={handleTriggerPuzzleAnimation}
<div >
className={`absolute inset-0 ${ <div className="relative w-full h-full">
isContentVisible {/* 内容层 - 始终渲染,根据状态控制显示 */}
? "z-50 opacity-100" // 显示时在上层
: "z-10 opacity-0 pointer-events-none" // 隐藏时在下层且不可交互
} transition-opacity duration-300 ease-in`}
>
<Outlet />
</div>
{/* 视频层 - 只在播放时渲染,z-index 动态调整 */}
{isVideoPlaying && (
<div <div
className={`absolute inset-0 ${ className={`absolute inset-0 ${
isVideoFadingOut isContentVisible
? "z-40 opacity-0 pointer-events-none" // 淡出时移到下层 ? "z-50 opacity-100" // 显示时在上层
: "z-50 opacity-100" // 播放时在上层 : "z-10 opacity-0 pointer-events-none" // 隐藏时在下层且不可交互
} bg-black transition-opacity duration-500 ease-out`} } transition-opacity duration-300 ease-in`}
> >
<Video <Outlet />
ref={videoRef} {/* 拼图盒 - 独立于内容层,确保始终可点击 */}
src={videoSrc ? getPublicPath(videoSrc) : undefined} <div
autoPlay ref={puzzleBoxRef}
playsInline className="absolute left-4 bottom-4 z-[1] cursor-pointer"
onEnded={handleVideoEnded} onClick={(e) => {
onError={handleVideoError} e.stopPropagation();
showSkipButton={true} if (residence) {
skipText="跳过" setIsPuzzleSystemVisible(true);
onSkip={handleSkip} }
/> }}
>
<Image src={getStaticImage("components/拼图盒")} button />
</div>
</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> </VideoProvider>
); );
}; };
const ResidenceLayout = () => {
return (
<PuzzleBoxProvider>
<ResidenceLayoutContent />
</PuzzleBoxProvider>
);
};
export default ResidenceLayout; 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;
+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";
+138
View File
@@ -0,0 +1,138 @@
/**
* 拼图碎片数据配置
* 存储每个碎片的标题、场景图片等数据
*/
export interface PuzzlePieceData {
/** 碎片标题 */
title: string;
/** 对应的场景图片路径(相对于 puzzle/ 目录) */
detailImage: string;
// 对应的场景路由
route: string;
}
/**
* 每个故居的拼图碎片数据配置
* 格式:{ residence: { pieceId: PuzzlePieceData } }
*/
export const puzzleDataConfig: Record<
string,
Record<number, PuzzlePieceData>
> = {
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;
};
+45
View File
@@ -0,0 +1,45 @@
/**
* 展品与拼图碎片映射配置
* 定义每个展品对应解锁的碎片ID
*
* 格式:{ residence: { exhibitId: pieceId } }
* 示例:chenyun: { 1: 1 } 表示陈云故居的展品1对应碎片1
*/
export const puzzleExhibitMapping: Record<
string,
Record<number, number>
> = {
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;
};
+146
View File
@@ -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<number, PuzzlePieceLayout>
> = {
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%",
},
};
};
+52
View File
@@ -0,0 +1,52 @@
/**
* 拼图碎片与页面路由映射配置
* 格式:故居名 -> 碎片ID -> 路由路径(相对于/:moduleName/)
*
* 示例:
* - 碎片1 对应 place1 页面
* - 碎片2 对应 place2 页面
* - 碎片3 对应 reflection 页面
* - 碎片4 对应 internalization 页面
*
* 注意:路由路径中的 :moduleName 会在使用时替换为实际的 moduleName
*/
export const puzzleMapping: Record<string, Record<number, string>> = {
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}`;
};
+34
View File
@@ -0,0 +1,34 @@
import { createContext, useContext, ReactNode } from "react";
interface PuzzleAnimationContextType {
/** 触发碎片动画,传递碎片ID */
triggerPuzzleAnimation: (pieceId: number) => void;
}
const PuzzleAnimationContext = createContext<PuzzleAnimationContextType | null>(
null
);
export const PuzzleAnimationProvider = ({
children,
triggerPuzzleAnimation,
}: {
children: ReactNode;
triggerPuzzleAnimation: (pieceId: number) => void;
}) => {
return (
<PuzzleAnimationContext.Provider value={{ triggerPuzzleAnimation }}>
{children}
</PuzzleAnimationContext.Provider>
);
};
export const usePuzzleAnimation = () => {
const context = useContext(PuzzleAnimationContext);
if (!context) {
throw new Error(
"usePuzzleAnimation must be used within PuzzleAnimationProvider"
);
}
return context;
};
+26
View File
@@ -0,0 +1,26 @@
import { createContext, useContext, useRef, ReactNode } from "react";
interface PuzzleBoxContextType {
puzzleBoxRef: React.RefObject<HTMLDivElement | null>;
}
const PuzzleBoxContext = createContext<PuzzleBoxContextType | null>(null);
export const PuzzleBoxProvider = ({ children }: { children: ReactNode }) => {
const puzzleBoxRef = useRef<HTMLDivElement>(null);
return (
<PuzzleBoxContext.Provider value={{ puzzleBoxRef }}>
{children}
</PuzzleBoxContext.Provider>
);
};
export const usePuzzleBox = () => {
const context = useContext(PuzzleBoxContext);
if (!context) {
throw new Error("usePuzzleBox must be used within PuzzleBoxProvider");
}
return context;
};
+5 -1
View File
@@ -13,7 +13,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
import { SuccessAlert, DragQuizPanel } from "@/components/layout"; import { SuccessAlert, DragQuizPanel } from "@/components/layout";
import { MultipleChoiceQuizPanel } from "@/components/layout/quiz"; import { MultipleChoiceQuizPanel } from "@/components/layout/quiz";
import { GuideSteps, GuideStepsItem } from "@/components"; import { GuideSteps, GuideStepsItem } from "@/components";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
type QuizOption = { type QuizOption = {
value: string; value: string;
label: string; label: string;
@@ -100,6 +100,8 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
const ChenyunInternalization = () => { const ChenyunInternalization = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { moduleName } = useParams<{ moduleName: string }>(); const { moduleName } = useParams<{ moduleName: string }>();
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation();
const [activeSuccess, setActiveSuccess] = useState<string | null>(null); const [activeSuccess, setActiveSuccess] = useState<string | null>(null);
const [successContent, setSuccessContent] = useState<string>(""); const [successContent, setSuccessContent] = useState<string>("");
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>( const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(
@@ -196,6 +198,7 @@ const ChenyunInternalization = () => {
onContinue={() => { onContinue={() => {
setActiveSuccess(null); setActiveSuccess(null);
setSuccessContent(""); setSuccessContent("");
triggerPuzzleAnimation(16);
setDragQuizValues((prev) => { setDragQuizValues((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[item.id]; delete next[item.id];
@@ -242,6 +245,7 @@ const ChenyunInternalization = () => {
if (isCorrect) { if (isCorrect) {
setActiveSuccess(item.id); setActiveSuccess(item.id);
setSuccessContent("回答正确,请继续探索。"); setSuccessContent("回答正确,请继续探索。");
if (item.id === "quiz-1") triggerPuzzleAnimation(15);
setResumeCallback(() => resume); setResumeCallback(() => resume);
} }
}} }}
+22 -7
View File
@@ -11,6 +11,8 @@ import { useNavigate, useParams } from "react-router-dom";
import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState } from "react"; import { useState } from "react";
import Cursor from "@/components/cursor"; import Cursor from "@/components/cursor";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
import { useChenyunStore } from "@/store/chenyunStore";
// starName 固定为 chenyun // starName 固定为 chenyun
const starName = "chenyun"; const starName = "chenyun";
@@ -28,6 +30,9 @@ const ChenyunPlace1 = () => {
moduleName: string; moduleName: string;
}>(); }>();
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation();
// 学习引导弹窗 // 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true); const [showGuideModal, setShowGuideModal] = useState(true);
// 对话结束后的学习引导弹窗 // 对话结束后的学习引导弹窗
@@ -39,11 +44,18 @@ const ChenyunPlace1 = () => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识) // 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
// 已浏览展品的 ID 集合
const [visited, setVisited] = useState<Set<number>>(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) => { const handleOpenExhibit = (index: 1 | 2) => {
setActiveExhibitId(index); setActiveExhibitId(index);
// ExhibitOverlay 打开时会自动通过 onOpen 通知 ResidenceLayout 执行动画
}; };
const handleStartLearning = () => { const handleStartLearning = () => {
@@ -128,13 +140,16 @@ const ChenyunPlace1 = () => {
<ExhibitOverlay <ExhibitOverlay
visible={activeExhibitId !== null} visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onOpen={() => {
// 示例:展品1对应碎片1,展品2对应碎片2
// 根据实际需求调用 triggerPuzzleAnimation
if (activeExhibitId === 1) {
triggerPuzzleAnimation(1);
}
}}
onClose={() => { onClose={() => {
if (activeExhibitId !== null) { if (activeExhibitId !== null) {
setVisited((prev) => { markExhibitVisited(place, activeExhibitId);
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
} }
setActiveExhibitId(null); setActiveExhibitId(null);
}} }}
+21 -7
View File
@@ -10,6 +10,8 @@ import {
} from "@/components"; } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
// starName 固定为 chenyun // starName 固定为 chenyun
const starName = "chenyun"; const starName = "chenyun";
@@ -31,10 +33,19 @@ const ChenyunPlace2 = () => {
moduleName: string; moduleName: string;
}>(); }>();
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation();
// 当前激活展品 ID,只存 ID 做唯一标识 // 当前激活展品 ID,只存 ID 做唯一标识
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null);
// 已浏览展品 ID 集合
const [visited, setVisited] = useState<Set<number>>(new Set()); // 使用持久化的访问状态
const place = "place2";
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore();
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place);
const visited = new Set(visitedExhibits);
// GuideSteps 显示状态 // GuideSteps 显示状态
const [guideVisible, setGuideVisible] = useState(false); const [guideVisible, setGuideVisible] = useState(false);
// GuideSteps 当前步骤 // GuideSteps 当前步骤
@@ -128,13 +139,16 @@ const ChenyunPlace2 = () => {
activeExhibitId ? exhibitDescriptionMap[activeExhibitId] : undefined activeExhibitId ? exhibitDescriptionMap[activeExhibitId] : undefined
} }
autoPlayDescription={activeExhibitId === 1} autoPlayDescription={activeExhibitId === 1}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(2);
} else if (activeExhibitId === 2) {
triggerPuzzleAnimation(3);
}
}}
onClose={() => { onClose={() => {
if (activeExhibitId !== null) { if (activeExhibitId !== null) {
setVisited((prev) => { markExhibitVisited(place, activeExhibitId);
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
} }
setActiveExhibitId(null); setActiveExhibitId(null);
}} }}
+21 -7
View File
@@ -11,6 +11,8 @@ import {
} from "@/components"; } from "@/components";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
// starName 固定为 chenyun // starName 固定为 chenyun
const starName = "chenyun"; const starName = "chenyun";
@@ -26,8 +28,18 @@ const ChenyunPlace3 = () => {
moduleName: string; moduleName: string;
}>(); }>();
const [visited, setVisited] = useState<Set<number>>(new Set()); // 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); 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); const [videoVisible, setVideoVisible] = useState(false);
// GuideSteps 显示状态 // GuideSteps 显示状态
const [guideVisible, setGuideVisible] = useState(false); const [guideVisible, setGuideVisible] = useState(false);
@@ -125,7 +137,8 @@ const ChenyunPlace3 = () => {
controls={false} controls={false}
onEnded={() => { onEnded={() => {
setVideoVisible(false); setVideoVisible(false);
setVisited((p) => new Set(p).add(2)); markExhibitVisited(place, 2);
triggerPuzzleAnimation(5);
}} }}
/> />
</div> </div>
@@ -135,13 +148,14 @@ const ChenyunPlace3 = () => {
<ExhibitOverlay <ExhibitOverlay
visible={activeExhibitId !== null} visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(4);
}
}}
onClose={() => { onClose={() => {
if (activeExhibitId !== null) { if (activeExhibitId !== null) {
setVisited((prev) => { markExhibitVisited(place, activeExhibitId);
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
} }
setActiveExhibitId(null); setActiveExhibitId(null);
}} }}
+20 -6
View File
@@ -10,6 +10,8 @@ import {
} from "@/components"; } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
// starName 固定为 chenyun // starName 固定为 chenyun
const starName = "chenyun"; const starName = "chenyun";
@@ -25,8 +27,19 @@ const ChenyunPlace4 = () => {
moduleName: string; moduleName: string;
}>(); }>();
const [visited, setVisited] = useState<Set<number>>(new Set()); // 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); 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引导对话 // 展品1引导对话
const [guideVisible, setGuideVisible] = useState(false); const [guideVisible, setGuideVisible] = useState(false);
const [guideStep, setGuideStep] = useState(0); const [guideStep, setGuideStep] = useState(0);
@@ -124,13 +137,14 @@ const ChenyunPlace4 = () => {
<ExhibitOverlay <ExhibitOverlay
visible={activeExhibitId !== null} visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(6);
}
}}
onClose={() => { onClose={() => {
if (activeExhibitId !== null) { if (activeExhibitId !== null) {
setVisited((prev) => { markExhibitVisited(place, activeExhibitId);
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
} }
setActiveExhibitId(null); setActiveExhibitId(null);
}} }}
+22 -11
View File
@@ -10,6 +10,8 @@ import {
} from "@/components"; } from "@/components";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
// starName 固定为 chenyun // starName 固定为 chenyun
const starName = "chenyun"; const starName = "chenyun";
@@ -32,10 +34,21 @@ const ChenyunPlace5 = () => {
moduleName: string; moduleName: string;
}>(); }>();
const [visited, setVisited] = useState<Set<number>>(new Set()); // 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>( const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(
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 [videoVisible3, setVideoVisible3] = useState(false);
// 学习引导弹窗 // 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true); const [showGuideModal, setShowGuideModal] = useState(true);
@@ -127,11 +140,8 @@ const ChenyunPlace5 = () => {
showSkipButton showSkipButton
onEnded={() => { onEnded={() => {
setVideoVisible3(false); setVideoVisible3(false);
setVisited((prev) => { markExhibitVisited(place, 3);
const next = new Set(prev); triggerPuzzleAnimation(8);
next.add(3);
return next;
});
}} }}
/> />
)} )}
@@ -144,13 +154,14 @@ const ChenyunPlace5 = () => {
activeExhibitId ? exhibitDescriptionMap[activeExhibitId] : undefined activeExhibitId ? exhibitDescriptionMap[activeExhibitId] : undefined
} }
autoPlayDescription={activeExhibitId === 2} autoPlayDescription={activeExhibitId === 2}
onOpen={() => {
if (activeExhibitId === 2) {
triggerPuzzleAnimation(7);
}
}}
onClose={() => { onClose={() => {
if (activeExhibitId !== null) { if (activeExhibitId !== null) {
setVisited((prev) => { markExhibitVisited(place, activeExhibitId);
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
} }
setActiveExhibitId(null); setActiveExhibitId(null);
}} }}
+20 -6
View File
@@ -10,6 +10,8 @@ import {
} from "@/components"; } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
// starName 固定为 chenyun // starName 固定为 chenyun
const starName = "chenyun"; const starName = "chenyun";
@@ -24,8 +26,19 @@ const ChenyunPlace6 = () => {
moduleName: string; moduleName: string;
}>(); }>();
const [visited, setVisited] = useState<Set<number>>(new Set()); // 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation();
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null); 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 [guideVisible, setGuideVisible] = useState(false);
const [guideStep, setGuideStep] = useState(0); const [guideStep, setGuideStep] = useState(0);
// 学习引导弹窗 // 学习引导弹窗
@@ -101,13 +114,14 @@ const ChenyunPlace6 = () => {
<ExhibitOverlay <ExhibitOverlay
visible={activeExhibitId !== null} visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(9);
}
}}
onClose={() => { onClose={() => {
if (activeExhibitId !== null) { if (activeExhibitId !== null) {
setVisited((prev) => { markExhibitVisited(place, activeExhibitId);
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
} }
setActiveExhibitId(null); setActiveExhibitId(null);
}} }}
+17 -11
View File
@@ -16,6 +16,8 @@ import { SuccessAlert, DragQuizPanel } from "@/components/layout";
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import type { VideoRef } from "@/components/video"; import type { VideoRef } from "@/components/video";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
// starName 固定为 chenyun // starName 固定为 chenyun
const starName = "chenyun"; const starName = "chenyun";
@@ -53,8 +55,19 @@ const ChenyunPlace7 = () => {
moduleName: string; moduleName: string;
}>(); }>();
const [visited, setVisited] = useState<Set<number>>(new Set()); // 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation();
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null); 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 [videoVisible1, setVideoVisible1] = useState(false);
const [videoPlaying, setVideoPlaying] = useState(false); const [videoPlaying, setVideoPlaying] = useState(false);
const videoRef = useRef<VideoRef>(null); const videoRef = useRef<VideoRef>(null);
@@ -231,11 +244,8 @@ const ChenyunPlace7 = () => {
showSkipButton showSkipButton
onEnded={() => { onEnded={() => {
setVideoPlaying(false); setVideoPlaying(false);
setVisited((prev) => { markExhibitVisited(place, 1);
const next = new Set(prev); triggerPuzzleAnimation(10);
next.add(1);
return next;
});
// 视频播放完成后,出现单选题 // 视频播放完成后,出现单选题
setQuizVisible(true); setQuizVisible(true);
}} }}
@@ -349,11 +359,7 @@ const ChenyunPlace7 = () => {
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onClose={() => { onClose={() => {
if (activeExhibitId !== null) { if (activeExhibitId !== null) {
setVisited((prev) => { markExhibitVisited(place, activeExhibitId);
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
} }
setActiveExhibitId(null); setActiveExhibitId(null);
}} }}
+20 -6
View File
@@ -10,6 +10,8 @@ import {
} from "@/components"; } from "@/components";
import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
// starName 固定为 chenyun // starName 固定为 chenyun
const starName = "chenyun"; const starName = "chenyun";
@@ -25,8 +27,19 @@ const ChenyunPlace8 = () => {
moduleName: string; moduleName: string;
}>(); }>();
const [visited, setVisited] = useState<Set<number>>(new Set()); // 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation();
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); 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的对话引导 // 展品2的对话引导
const [guideVisible, setGuideVisible] = useState(false); const [guideVisible, setGuideVisible] = useState(false);
const [guideStep, setGuideStep] = useState(0); const [guideStep, setGuideStep] = useState(0);
@@ -117,6 +130,11 @@ const ChenyunPlace8 = () => {
<ExhibitOverlay <ExhibitOverlay
visible={activeExhibitId !== null} visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onOpen={() => {
if (activeExhibitId === 1) {
triggerPuzzleAnimation(11);
}
}}
description={ description={
activeExhibitId === 1 activeExhibitId === 1
? `打赢"银元之战""两白一黑"战役的过程彰显了中国共产党治理经济的卓越能力!这里有一份"一五"计划报告的手稿,陈云在"一五"计划的编制中做出了重大贡献。一九五四年,陈云的主要工作之一,就是主持编制第一个五年计划纲要。这次编制工作是在前四次基础上进行的,陈云提出计划编制工作中要注意几个问题:首先是工业投资金额,其次是市场供应和人民生活,第三是农业发展速度,这三个问题对编制计划有指导意义。第一个五年计划纲要草案的重点是优先发展重工业,特别是156项重点工程。当时陈云重点抓重工业建设和粮食保障,通过"统购统销"稳定粮价,为重点工程项目筑牢根基,亲手铺就了我国工业化的起步之路。` ? `打赢"银元之战""两白一黑"战役的过程彰显了中国共产党治理经济的卓越能力!这里有一份"一五"计划报告的手稿,陈云在"一五"计划的编制中做出了重大贡献。一九五四年,陈云的主要工作之一,就是主持编制第一个五年计划纲要。这次编制工作是在前四次基础上进行的,陈云提出计划编制工作中要注意几个问题:首先是工业投资金额,其次是市场供应和人民生活,第三是农业发展速度,这三个问题对编制计划有指导意义。第一个五年计划纲要草案的重点是优先发展重工业,特别是156项重点工程。当时陈云重点抓重工业建设和粮食保障,通过"统购统销"稳定粮价,为重点工程项目筑牢根基,亲手铺就了我国工业化的起步之路。`
@@ -124,11 +142,7 @@ const ChenyunPlace8 = () => {
} }
onClose={() => { onClose={() => {
if (activeExhibitId !== null) { if (activeExhibitId !== null) {
setVisited((prev) => { markExhibitVisited(place, activeExhibitId);
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
} }
setActiveExhibitId(null); setActiveExhibitId(null);
}} }}
+17 -11
View File
@@ -12,6 +12,8 @@ import {
import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import type { VideoRef } from "@/components/video"; import type { VideoRef } from "@/components/video";
import { useChenyunStore } from "@/store/chenyunStore";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
// starName 固定为 chenyun // starName 固定为 chenyun
const starName = "chenyun"; const starName = "chenyun";
@@ -26,8 +28,19 @@ const ChenyunPlace9 = () => {
moduleName: string; moduleName: string;
}>(); }>();
const [visited, setVisited] = useState<Set<number>>(new Set()); // 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation();
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null); 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 [videoVisible, setVideoVisible] = useState(false);
const [videoPlaying, setVideoPlaying] = useState(false); const [videoPlaying, setVideoPlaying] = useState(false);
@@ -127,14 +140,11 @@ const ChenyunPlace9 = () => {
skipText="跳过" skipText="跳过"
onEnded={() => { onEnded={() => {
setVideoPlaying(false); setVideoPlaying(false);
setVisited((prev) => { markExhibitVisited(place, 1);
const next = new Set(prev);
next.add(1);
return next;
});
// 视频播放完成后,显示对话引导 // 视频播放完成后,显示对话引导
setGuideVisible(true); setGuideVisible(true);
setGuideStep(0); setGuideStep(0);
triggerPuzzleAnimation(12);
}} }}
/> />
</div> </div>
@@ -167,11 +177,7 @@ const ChenyunPlace9 = () => {
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""} src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""}
onClose={() => { onClose={() => {
if (activeExhibitId !== null) { if (activeExhibitId !== null) {
setVisited((prev) => { markExhibitVisited(place, activeExhibitId);
const next = new Set(prev);
next.add(activeExhibitId);
return next;
});
} }
setActiveExhibitId(null); setActiveExhibitId(null);
}} }}
+4 -11
View File
@@ -7,7 +7,7 @@ import ErrorAlert from "@/components/layout/alert/error-alert";
import ClueOverlay from "@/components/layout/clue"; import ClueOverlay from "@/components/layout/clue";
import type { ClueData } from "@/components/layout"; import type { ClueData } from "@/components/layout";
import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { getBackgroundImage, getStaticImage } from "@/lib/utils";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { WELCOME_TEXT, starName } from "./constants"; import { WELCOME_TEXT, starName } from "./constants";
import { useClueStates } from "./shared/useClueStates"; import { useClueStates } from "./shared/useClueStates";
@@ -17,6 +17,7 @@ import { MimianBattle } from "./mimian/MimianBattle";
import type { FileType } from "./shared/types"; import type { FileType } from "./shared/types";
import { YINYUAN_VIDEO_CONFIG } from "./constants/yinyuan"; import { YINYUAN_VIDEO_CONFIG } from "./constants/yinyuan";
import { MIMIAN_VIDEO_CONFIG } from "./constants/mimian"; import { MIMIAN_VIDEO_CONFIG } from "./constants/mimian";
import { useChenyunStore } from "@/store/chenyunStore";
const ChenyunReflection = () => { const ChenyunReflection = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -73,9 +74,8 @@ const ChenyunReflection = () => {
markClueAsViewed, markClueAsViewed,
} = useClueStates(); } = useClueStates();
// ========== 银元之战状态(从 YinyuanBattle 获取) ========== // ========== 银元之战状态(从 store 获取) ==========
// 注意:这些状态现在由 YinyuanBattle 内部管理,这里只保留必要的共享状态 const { isYinyuanSolved } = useChenyunStore();
const [isYinyuanSolved, setIsYinyuanSolved] = useState(false);
// ========== 事件处理函数 ========== // ========== 事件处理函数 ==========
@@ -223,7 +223,6 @@ const ChenyunReflection = () => {
onPlayVideo={(fileType) => { onPlayVideo={(fileType) => {
setPlayingVideo(fileType); setPlayingVideo(fileType);
}} }}
onSolvedChange={setIsYinyuanSolved}
/> />
{/* 米棉之战模块 */} {/* 米棉之战模块 */}
@@ -238,12 +237,6 @@ const ChenyunReflection = () => {
/> />
</div> </div>
{/* 拼图盒 */}
<Image
className="absolute left-4 bottom-4"
src={getStaticImage("components/拼图盒")}
/>
{/* 欢迎语层 - 使用 StudyGuideModal,仅承担学习引导/欢迎语 */} {/* 欢迎语层 - 使用 StudyGuideModal,仅承担学习引导/欢迎语 */}
<StudyGuideModal <StudyGuideModal
visible={showWelcomeGuide} visible={showWelcomeGuide}
@@ -7,6 +7,9 @@ import { MimianFile } from "./MimianFile";
import { MimianClues } from "./MimianClues"; import { MimianClues } from "./MimianClues";
import { MimianQuizzes } from "./MimianQuizzes"; import { MimianQuizzes } from "./MimianQuizzes";
import { useMimianState } from "./useMimianState"; import { useMimianState } from "./useMimianState";
import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext";
import { usePuzzleStore } from "@/store/puzzleStore";
import { starName } from "../constants";
import type { FileType } from "../shared/types"; import type { FileType } from "../shared/types";
import type { ClueData } from "@/components/layout"; import type { ClueData } from "@/components/layout";
@@ -41,6 +44,8 @@ export const MimianBattle = ({
onEnd, onEnd,
}: MimianBattleProps) => { }: MimianBattleProps) => {
const state = useMimianState(); const state = useMimianState();
const { triggerPuzzleAnimation } = usePuzzleAnimation();
const { isPuzzleUnlocked } = usePuzzleStore();
// 如果所有米棉之战线索都已查看,自动显示单选题1 // 如果所有米棉之战线索都已查看,自动显示单选题1
useEffect(() => { useEffect(() => {
@@ -104,6 +109,12 @@ export const MimianBattle = ({
state.setMimianSuccessAlertContent(""); state.setMimianSuccessAlertContent("");
// 显示旁白2(图4) // 显示旁白2(图4)
state.setShowMimianNarration2(true); state.setShowMimianNarration2(true);
// 检查碎片14是否已解锁,如果未解锁则触发动画
const pieceId = 14;
if (!isPuzzleUnlocked(starName, pieceId)) {
triggerPuzzleAnimation(pieceId);
}
}; };
// 处理错误提示框继续 // 处理错误提示框继续
@@ -3,12 +3,21 @@
* 管理米棉之战相关的所有状态 * 管理米棉之战相关的所有状态
*/ */
import { useState } from "react"; import { useState } from "react";
import { useChenyunStore } from "@/store/chenyunStore";
/** /**
* 米棉之战状态管理 Hook * 米棉之战状态管理 Hook
* @returns 米棉之战的所有状态和更新函数 * @returns 米棉之战的所有状态和更新函数
*/ */
export const useMimianState = () => { export const useMimianState = () => {
// 直接从 store 获取持久化的解决状态,不需要本地状态
const { isMimianSolved, setMimianSolved } = useChenyunStore();
// 更新解决状态时,直接更新 store
const handleSetIsMimianSolved = (solved: boolean) => {
setMimianSolved(solved);
};
// 单选题1相关状态 // 单选题1相关状态
const [showMimianQuiz1, setShowMimianQuiz1] = useState(false); const [showMimianQuiz1, setShowMimianQuiz1] = useState(false);
@@ -36,9 +45,6 @@ export const useMimianState = () => {
// 旁白5相关状态(结束语) // 旁白5相关状态(结束语)
const [showMimianNarration5, setShowMimianNarration5] = useState(false); const [showMimianNarration5, setShowMimianNarration5] = useState(false);
// 米棉之战解决状态
const [isMimianSolved, setIsMimianSolved] = useState(false);
// 米棉之战成功提示框状态 // 米棉之战成功提示框状态
const [showMimianSuccessAlert, setShowMimianSuccessAlert] = useState(false); const [showMimianSuccessAlert, setShowMimianSuccessAlert] = useState(false);
const [mimianSuccessAlertContent, setMimianSuccessAlertContent] = const [mimianSuccessAlertContent, setMimianSuccessAlertContent] =
@@ -75,7 +81,7 @@ export const useMimianState = () => {
// 解决状态 // 解决状态
isMimianSolved, isMimianSolved,
setIsMimianSolved, setIsMimianSolved: handleSetIsMimianSolved,
}; };
}; };
@@ -8,6 +8,9 @@ import { YinyuanClues } from "./YinyuanClues";
import { YinyuanQuizzes } from "./YinyuanQuizzes"; import { YinyuanQuizzes } from "./YinyuanQuizzes";
import { YinyuanGuides } from "./YinyuanGuides"; import { YinyuanGuides } from "./YinyuanGuides";
import { useYinyuanState } from "./useYinyuanState"; 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 { FileType } from "../shared/types";
import type { ClueData } from "@/components/layout"; import type { ClueData } from "@/components/layout";
@@ -24,8 +27,6 @@ export interface YinyuanBattleProps {
onClueClick: (clue: ClueData, clueKey: string) => void; onClueClick: (clue: ClueData, clueKey: string) => void;
/** 播放视频回调 */ /** 播放视频回调 */
onPlayVideo: (fileType: FileType) => void; onPlayVideo: (fileType: FileType) => void;
/** 银元之战解决状态变化回调 */
onSolvedChange?: (solved: boolean) => void;
} }
/** /**
@@ -39,16 +40,10 @@ export const YinyuanBattle = ({
onFileClick, onFileClick,
onClueClick, onClueClick,
onPlayVideo, onPlayVideo,
onSolvedChange,
}: YinyuanBattleProps) => { }: YinyuanBattleProps) => {
const state = useYinyuanState(); const state = useYinyuanState();
const { triggerPuzzleAnimation } = usePuzzleAnimation();
// 当银元之战解决状态变化时,通知父组件 const { isPuzzleUnlocked } = usePuzzleStore();
useEffect(() => {
if (onSolvedChange) {
onSolvedChange(state.isYinyuanSolved);
}
}, [state.isYinyuanSolved, onSolvedChange]);
// 监听"前往解决"按钮出现的条件,显示第一个提示 // 监听"前往解决"按钮出现的条件,显示第一个提示
useEffect(() => { useEffect(() => {
@@ -157,6 +152,12 @@ export const YinyuanBattle = ({
// 关闭聚光灯、标记银元之战为已解决、显示走势图 // 关闭聚光灯、标记银元之战为已解决、显示走势图
state.setIsYinyuanSolved(true); state.setIsYinyuanSolved(true);
state.setShowSuccessAlert(false); state.setShowSuccessAlert(false);
// 检查碎片13是否已解锁,如果未解锁则触发动画
const pieceId = 13;
if (!isPuzzleUnlocked(starName, pieceId)) {
triggerPuzzleAnimation(pieceId);
}
}; };
// 处理走势图点击 // 处理走势图点击
@@ -78,11 +78,9 @@ export const YinyuanGuides = ({
visible={showTrendChartDetail} visible={showTrendChartDetail}
src="chenyun/reflection/银元价格走势图" src="chenyun/reflection/银元价格走势图"
description={`以上海为中心,代表"人民币信用"的绿色区域迅速覆盖全市,并向外辐射。`} description={`以上海为中心,代表"人民币信用"的绿色区域迅速覆盖全市,并向外辐射。`}
disableDrag={true}
onClick={onTrendChartClick} onClick={onTrendChartClick}
onClose={onTrendChartDetailClose} onClose={onTrendChartDetailClose}
/> />
</> </>
); );
}; };
@@ -3,12 +3,21 @@
* 管理银元之战相关的所有状态 * 管理银元之战相关的所有状态
*/ */
import { useState } from "react"; import { useState } from "react";
import { useChenyunStore } from "@/store/chenyunStore";
/** /**
* 银元之战状态管理 Hook * 银元之战状态管理 Hook
* @returns 银元之战的所有状态和更新函数 * @returns 银元之战的所有状态和更新函数
*/ */
export const useYinyuanState = () => { export const useYinyuanState = () => {
// 直接从 store 获取持久化的解决状态,不需要本地状态
const { isYinyuanSolved, setYinyuanSolved } = useChenyunStore();
// 更新解决状态时,直接更新 store
const handleSetIsYinyuanSolved = (solved: boolean) => {
setYinyuanSolved(solved);
};
// 多选题1相关状态 // 多选题1相关状态
const [showQuiz1, setShowQuiz1] = useState(false); const [showQuiz1, setShowQuiz1] = useState(false);
@@ -22,9 +31,6 @@ export const useYinyuanState = () => {
// 成功提示框状态 // 成功提示框状态
const [showSuccessAlert, setShowSuccessAlert] = useState(false); const [showSuccessAlert, setShowSuccessAlert] = useState(false);
// 银元之战解决状态
const [isYinyuanSolved, setIsYinyuanSolved] = useState(false);
// 走势图图标显示状态(控制文件上的走势图图标是否显示) // 走势图图标显示状态(控制文件上的走势图图标是否显示)
const [showTrendChartIcon, setShowTrendChartIcon] = useState(true); const [showTrendChartIcon, setShowTrendChartIcon] = useState(true);
@@ -60,7 +66,7 @@ export const useYinyuanState = () => {
// 解决状态 // 解决状态
isYinyuanSolved, isYinyuanSolved,
setIsYinyuanSolved, setIsYinyuanSolved: handleSetIsYinyuanSolved,
// 走势图相关状态 // 走势图相关状态
showTrendChartIcon, showTrendChartIcon,
@@ -83,4 +89,3 @@ export const useYinyuanState = () => {
setHasShownGuide3, setHasShownGuide3,
}; };
}; };
+1 -1
View File
@@ -269,7 +269,7 @@ const SunzhongshanReflectionScene1 = () => {
{showVideo && ( {showVideo && (
<div className="fixed inset-0 z-[9999] bg-black"> <div className="fixed inset-0 z-[9999] bg-black">
<Video <Video
src={getPublicPath(`video/${starName}/sunzhongshan_10.mp4`)} src={getPublicPath(`video/${starName}/sunzhongshan_11.mp4`)}
className="w-full h-full" className="w-full h-full"
onEnded={handleVideoEnd} onEnded={handleVideoEnd}
autoPlay={true} autoPlay={true}
+1 -1
View File
@@ -283,7 +283,7 @@ const SunzhongshanReflectionScene2 = () => {
{showVideo && ( {showVideo && (
<div className="fixed inset-0 z-[9999] bg-black"> <div className="fixed inset-0 z-[9999] bg-black">
<Video <Video
src={getPublicPath(`video/${starName}/sunzhongshan_10.mp4`)} src={getPublicPath(`video/${starName}/sunzhongshan_11.mp4`)}
className="w-full h-full" className="w-full h-full"
onEnded={handleVideoEnd} onEnded={handleVideoEnd}
autoPlay={true} autoPlay={true}
+1 -1
View File
@@ -311,7 +311,7 @@ const SunzhongshanReflectionScene3 = () => {
{showVideo && ( {showVideo && (
<div className="fixed inset-0 z-[9999] bg-black"> <div className="fixed inset-0 z-[9999] bg-black">
<Video <Video
src={getPublicPath(`video/${starName}/sunzhongshan_10.mp4`)} src={getPublicPath(`video/${starName}/sunzhongshan_11.mp4`)}
className="w-full h-full" className="w-full h-full"
onEnded={handleVideoEnd} onEnded={handleVideoEnd}
autoPlay={true} autoPlay={true}
+142
View File
@@ -0,0 +1,142 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface ChenyunStore {
// 存储每个场景(place)的已访问展品ID集合
// 格式:{ place1: Set<1, 2>, place2: Set<1, 2>, ... }
visitedExhibits: Record<string, Set<number>>;
// 标记展品为已访问
markExhibitVisited: (place: string, exhibitId: number) => void;
// 检查展品是否已访问
isExhibitVisited: (place: string, exhibitId: number) => boolean;
// 获取某个场景已访问的展品ID数组
getVisitedExhibits: (place: string) => number[];
// 重置某个场景的访问记录
resetPlace: (place: string) => void;
// 重置所有访问记录
resetAll: () => void;
// ========== 体悟模块状态 ==========
// 银元之战是否已解决
isYinyuanSolved: boolean;
// 设置银元之战解决状态
setYinyuanSolved: (solved: boolean) => void;
// 米棉之战是否已解决
isMimianSolved: boolean;
// 设置米棉之战解决状态
setMimianSolved: (solved: boolean) => void;
}
// 将 Set 转换为数组以便序列化
const serializeVisitedExhibits = (
visitedExhibits: Record<string, Set<number>>
): Record<string, number[]> => {
const serialized: Record<string, number[]> = {};
for (const [place, exhibits] of Object.entries(visitedExhibits)) {
serialized[place] = Array.from(exhibits);
}
return serialized;
};
// 将数组转换回 Set
const deserializeVisitedExhibits = (
serialized: Record<string, number[]> | undefined
): Record<string, Set<number>> => {
if (!serialized || typeof serialized !== "object") {
return {};
}
const visitedExhibits: Record<string, Set<number>> = {};
for (const [place, exhibits] of Object.entries(serialized)) {
if (Array.isArray(exhibits)) {
visitedExhibits[place] = new Set(exhibits);
} else {
visitedExhibits[place] = new Set();
}
}
return visitedExhibits;
};
export const useChenyunStore = create<ChenyunStore>()(
persist(
(set, get) => ({
visitedExhibits: {},
markExhibitVisited: (place: string, exhibitId: number) => {
set((state) => {
const currentExhibits =
state.visitedExhibits[place] || new Set<number>();
if (!currentExhibits.has(exhibitId)) {
const newExhibits = {
...state.visitedExhibits,
[place]: new Set([...currentExhibits, exhibitId]),
};
return { visitedExhibits: newExhibits };
}
return state;
});
},
isExhibitVisited: (place: string, exhibitId: number) => {
const { visitedExhibits } = get();
return visitedExhibits[place]?.has(exhibitId) || false;
},
getVisitedExhibits: (place: string) => {
const { visitedExhibits } = get();
return Array.from(visitedExhibits[place] || []);
},
resetPlace: (place: string) => {
set((state) => {
const newExhibits = { ...state.visitedExhibits };
delete newExhibits[place];
return { visitedExhibits: newExhibits };
});
},
resetAll: () => {
set({ visitedExhibits: {} });
},
// ========== 体悟模块状态 ==========
isYinyuanSolved: false,
setYinyuanSolved: (solved: boolean) => {
set({ isYinyuanSolved: solved });
},
isMimianSolved: false,
setMimianSolved: (solved: boolean) => {
set({ isMimianSolved: solved });
},
}),
{
name: "chenyun-storage", // localStorage key
partialize: (state) => ({
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
isYinyuanSolved: state.isYinyuanSolved,
isMimianSolved: state.isMimianSolved,
}),
onRehydrateStorage: () => (state) => {
if (state) {
// 确保反序列化后的数据格式正确
state.visitedExhibits = deserializeVisitedExhibits(
state.visitedExhibits as any
);
// 确保解决状态有默认值
if (state.isYinyuanSolved === undefined) {
state.isYinyuanSolved = false;
}
if (state.isMimianSolved === undefined) {
state.isMimianSolved = false;
}
}
},
}
)
);
+152
View File
@@ -0,0 +1,152 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface PuzzleStore {
// 每个故居的已解锁碎片集合,格式:{ residence: Set<pieceId> }
puzzles: Record<string, Set<number>>;
// 解锁碎片
unlockPuzzle: (residence: string, pieceId: number) => void;
// 检查碎片是否解锁
isPuzzleUnlocked: (residence: string, pieceId: number) => boolean;
// 获取已解锁碎片列表
getUnlockedPuzzles: (residence: string) => number[];
// 重置指定故居的所有碎片
resetPuzzles: (residence: string) => void;
// 获取收集进度
getPuzzleCollectProgress: (residence: string) => number;
}
// 将 Set 转换为数组以便序列化
const serializePuzzles = (puzzles: Record<string, Set<number>>) => {
const serialized: Record<string, number[]> = {};
for (const [residence, pieces] of Object.entries(puzzles)) {
serialized[residence] = Array.from(pieces);
}
return serialized;
};
// 将数组转换回 Set
const deserializePuzzles = (
serialized: Record<string, number[]> | undefined
) => {
if (!serialized || typeof serialized !== "object") {
return {};
}
const puzzles: Record<string, Set<number>> = {};
for (const [residence, pieces] of Object.entries(serialized)) {
// 确保 pieces 是数组
if (Array.isArray(pieces)) {
puzzles[residence] = new Set(pieces);
} else {
// 如果格式不对,创建新的 Set
puzzles[residence] = new Set();
}
}
return puzzles;
};
// 辅助函数:确保 puzzles 中的值都是 Set
const ensurePuzzlesAreSets = (puzzles: any): Record<string, Set<number>> => {
const fixed: Record<string, Set<number>> = {};
if (puzzles && typeof puzzles === "object") {
for (const [residence, pieces] of Object.entries(puzzles)) {
if (pieces instanceof Set) {
fixed[residence] = pieces;
} else if (Array.isArray(pieces)) {
fixed[residence] = new Set(pieces);
} else {
fixed[residence] = new Set();
}
}
}
return fixed;
};
export const usePuzzleStore = create<PuzzleStore>()(
persist(
(set, get) => ({
puzzles: {},
unlockPuzzle: (residence: string, pieceId: number) => {
const { puzzles } = get();
// 确保 puzzles 格式正确
const fixedPuzzles = ensurePuzzlesAreSets(puzzles);
const residencePuzzles = fixedPuzzles[residence] || new Set<number>();
if (!residencePuzzles.has(pieceId)) {
const newPuzzles = { ...fixedPuzzles };
newPuzzles[residence] = new Set([...residencePuzzles, pieceId]);
set({ puzzles: newPuzzles });
}
},
isPuzzleUnlocked: (residence: string, pieceId: number) => {
const { puzzles } = get();
// 确保 puzzles 格式正确
const fixedPuzzles = ensurePuzzlesAreSets(puzzles);
const residencePuzzles = fixedPuzzles[residence];
if (!residencePuzzles) {
return false;
}
return residencePuzzles.has(pieceId);
},
getUnlockedPuzzles: (residence: string) => {
const { puzzles } = get();
// 确保 puzzles 格式正确
const fixedPuzzles = ensurePuzzlesAreSets(puzzles);
const residencePuzzles = fixedPuzzles[residence];
if (!residencePuzzles) {
return [];
}
return Array.from(residencePuzzles);
},
getPuzzleCollectProgress: (residence: string): number => {
const totalPuzzles = 16;
const { puzzles } = get();
const fixedPuzzles = ensurePuzzlesAreSets(puzzles);
const residencePuzzles = fixedPuzzles[residence];
if (!residencePuzzles) {
return 0;
}
return (residencePuzzles.size / totalPuzzles) * 100;
},
resetPuzzles: (residence: string) => {
const { puzzles } = get();
// 确保 puzzles 格式正确
const fixedPuzzles = ensurePuzzlesAreSets(puzzles);
const newPuzzles = { ...fixedPuzzles };
delete newPuzzles[residence];
set({ puzzles: newPuzzles });
},
}),
{
name: "puzzle-storage", // localStorage key
partialize: (state) => ({
puzzles: serializePuzzles(state.puzzles),
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as {
puzzles?: Record<string, number[]>;
};
return {
...currentState,
puzzles: deserializePuzzles(persisted?.puzzles),
};
},
onRehydrateStorage: () => (state) => {
// 在恢复时,确保所有 puzzles 都是 Set
if (state) {
state.puzzles = ensurePuzzlesAreSets(state.puzzles);
}
},
}
)
);