feat:添加孙中山场景
No files matched your search
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 409 KiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 787 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 966 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 698 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 686 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 810 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 819 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 863 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 691 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 780 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 616 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 678 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 698 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
@@ -0,0 +1,245 @@
|
|||||||
|
import { createRoot, Root } from "react-dom/client";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export type FlyToPosition =
|
||||||
|
| "center"
|
||||||
|
| "top-left"
|
||||||
|
| "top-right"
|
||||||
|
| "bottom-left"
|
||||||
|
| "bottom-right";
|
||||||
|
|
||||||
|
export interface FlyToAnimationOptions {
|
||||||
|
/** 起始元素的 ref */
|
||||||
|
fromRef: React.RefObject<HTMLElement | null>;
|
||||||
|
/** 目标元素的 ref */
|
||||||
|
toRef: React.RefObject<HTMLElement | null>;
|
||||||
|
/** 目标位置(相对于目标元素) */
|
||||||
|
targetPosition?: FlyToPosition;
|
||||||
|
/** 动画持续时间(毫秒) */
|
||||||
|
duration?: number;
|
||||||
|
/** 动画缓动函数 */
|
||||||
|
ease?: string | number[];
|
||||||
|
/** 动画完成回调 */
|
||||||
|
onComplete?: () => void;
|
||||||
|
/** 自定义样式 */
|
||||||
|
className?: string;
|
||||||
|
/** 缩放比例(从起始到目标) */
|
||||||
|
scale?: { from: number; to: number };
|
||||||
|
/** 透明度(从起始到目标) */
|
||||||
|
opacity?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnimationInstance extends FlyToAnimationOptions {
|
||||||
|
id: string;
|
||||||
|
startPos: { x: number; y: number };
|
||||||
|
targetPos: { x: number; y: number };
|
||||||
|
elementHtml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlyToAnimationManagerClass {
|
||||||
|
private animations: Map<string, AnimationInstance> = new Map();
|
||||||
|
private containerRoot: Root | null = null;
|
||||||
|
|
||||||
|
// 初始化容器
|
||||||
|
private initContainer() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (this.containerRoot) return;
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.id = "fly-to-animation-container";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
this.containerRoot = createRoot(container);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染动画容器
|
||||||
|
private render() {
|
||||||
|
if (!this.containerRoot) return;
|
||||||
|
|
||||||
|
const animations = Array.from(this.animations.values());
|
||||||
|
|
||||||
|
this.containerRoot.render(
|
||||||
|
<>
|
||||||
|
{animations.map((animation) => (
|
||||||
|
<FlyToAnimationItem key={animation.id} animation={animation} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知更新
|
||||||
|
private notify() {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建动画
|
||||||
|
flyTo(options: FlyToAnimationOptions) {
|
||||||
|
const { fromRef, toRef } = options;
|
||||||
|
|
||||||
|
if (!fromRef?.current || !toRef?.current) {
|
||||||
|
console.warn("FlyToAnimation: fromRef 或 toRef 未设置");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initContainer();
|
||||||
|
|
||||||
|
// 使用 requestAnimationFrame 确保在下一帧获取准确的位置
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const fromRect = fromRef.current!.getBoundingClientRect();
|
||||||
|
const toRect = toRef.current!.getBoundingClientRect();
|
||||||
|
|
||||||
|
// 计算起始位置(起始元素的中心)
|
||||||
|
const startX = fromRect.left + fromRect.width / 2;
|
||||||
|
const startY = fromRect.top + fromRect.height / 2;
|
||||||
|
|
||||||
|
// 计算目标位置
|
||||||
|
const targetPosition = options.targetPosition || "center";
|
||||||
|
let targetX = 0;
|
||||||
|
let targetY = 0;
|
||||||
|
|
||||||
|
switch (targetPosition) {
|
||||||
|
case "center":
|
||||||
|
targetX = toRect.left + toRect.width / 2;
|
||||||
|
targetY = toRect.top + toRect.height / 2;
|
||||||
|
break;
|
||||||
|
case "top-left":
|
||||||
|
targetX = toRect.left;
|
||||||
|
targetY = toRect.top;
|
||||||
|
break;
|
||||||
|
case "top-right":
|
||||||
|
targetX = toRect.right;
|
||||||
|
targetY = toRect.top;
|
||||||
|
break;
|
||||||
|
case "bottom-left":
|
||||||
|
targetX = toRect.left;
|
||||||
|
targetY = toRect.bottom;
|
||||||
|
break;
|
||||||
|
case "bottom-right":
|
||||||
|
targetX = toRect.right;
|
||||||
|
targetY = toRect.bottom;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 克隆起始元素的 HTML 内容和样式
|
||||||
|
// 使用 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);
|
||||||
|
if (value) {
|
||||||
|
clonedElement.style.setProperty(prop, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取克隆后的 HTML(只获取内部内容,不包含外层元素)
|
||||||
|
const elementHtml = clonedElement.innerHTML;
|
||||||
|
|
||||||
|
// 创建动画实例
|
||||||
|
const id = `fly-to-${Date.now()}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 11)}`;
|
||||||
|
const instance: AnimationInstance = {
|
||||||
|
id,
|
||||||
|
...options,
|
||||||
|
startPos: { x: startX, y: startY },
|
||||||
|
targetPos: { x: targetX, y: targetY },
|
||||||
|
elementHtml,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.animations.set(id, instance);
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
// 动画完成后自动清理
|
||||||
|
const duration = options.duration || 1200;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.remove(id);
|
||||||
|
options.onComplete?.();
|
||||||
|
}, duration);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除动画
|
||||||
|
remove(id: string) {
|
||||||
|
this.animations.delete(id);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建全局单例
|
||||||
|
export const flyToAnimationManager = new FlyToAnimationManagerClass();
|
||||||
|
|
||||||
|
// 单个动画项组件
|
||||||
|
const FlyToAnimationItem = ({
|
||||||
|
animation,
|
||||||
|
}: {
|
||||||
|
animation: AnimationInstance;
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
startPos,
|
||||||
|
targetPos,
|
||||||
|
elementHtml,
|
||||||
|
duration = 1200,
|
||||||
|
ease = "easeInOut",
|
||||||
|
className = "",
|
||||||
|
scale = { from: 1, to: 0.3 },
|
||||||
|
opacity = [1, 0.8, 0],
|
||||||
|
} = animation;
|
||||||
|
|
||||||
|
// 计算 opacity 的 times 数组
|
||||||
|
const opacityTimes =
|
||||||
|
opacity.length > 1
|
||||||
|
? opacity.map((_, i) => i / (opacity.length - 1))
|
||||||
|
: [0, 1];
|
||||||
|
|
||||||
|
// 如果 opacity 是数组,使用整个数组;否则使用单个值
|
||||||
|
const animateOpacity =
|
||||||
|
opacity.length > 1 ? opacity : opacity[opacity.length - 1] ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{
|
||||||
|
x: startPos.x,
|
||||||
|
y: startPos.y,
|
||||||
|
scale: scale.from,
|
||||||
|
opacity: opacity[0] ?? 1,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
x: targetPos.x,
|
||||||
|
y: targetPos.y,
|
||||||
|
scale: scale.to,
|
||||||
|
opacity: animateOpacity,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: duration / 1000,
|
||||||
|
ease: ease as any,
|
||||||
|
opacity:
|
||||||
|
opacity.length > 1
|
||||||
|
? {
|
||||||
|
times: opacityTimes,
|
||||||
|
duration: duration / 1000,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
duration: duration / 1000,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className={`fixed pointer-events-none z-[99999] ${className}`}
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
}}
|
||||||
|
transformTemplate={({ x, y, scale }) =>
|
||||||
|
`translate(-50%, -50%) translateX(${x}) translateY(${y}) scale(${scale})`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: elementHtml }}
|
||||||
|
style={{ pointerEvents: "none" }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { flyToAnimationManager } from "./FlyToAnimationManager";
|
||||||
|
import type { FlyToAnimationOptions } from "./FlyToAnimationManager";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 飞行动画函数
|
||||||
|
* 将一个元素从起始位置飞向目标元素
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const fromRef = useRef<HTMLDivElement>(null);
|
||||||
|
* const toRef = useRef<HTMLDivElement>(null);
|
||||||
|
*
|
||||||
|
* flyToAnimation({
|
||||||
|
* fromRef,
|
||||||
|
* toRef,
|
||||||
|
* targetPosition: "center",
|
||||||
|
* onComplete: () => console.log("动画完成"),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const flyToAnimation = (options: FlyToAnimationOptions) => {
|
||||||
|
flyToAnimationManager.flyTo(options);
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// 函数式 API
|
||||||
|
export { flyToAnimation } from "./flyToAnimation";
|
||||||
|
export type { FlyToAnimationOptions } from "./FlyToAnimationManager";
|
||||||
|
export type { FlyToPosition } from "./FlyToAnimationManager";
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Image } from "@/components";
|
||||||
|
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Clue } from "@/store/clueStore";
|
||||||
|
|
||||||
|
export interface ClueBookModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
clues: Clue[];
|
||||||
|
onClose: () => void;
|
||||||
|
getClueImage: (clueId: string) => string;
|
||||||
|
getClueDetailImage: (clueId: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 线索簿组件 - 显示已收集的线索和详情
|
||||||
|
*/
|
||||||
|
export const ClueBookModal = ({
|
||||||
|
visible,
|
||||||
|
clues,
|
||||||
|
onClose,
|
||||||
|
getClueImage,
|
||||||
|
getClueDetailImage,
|
||||||
|
}: ClueBookModalProps) => {
|
||||||
|
const [selectedClueId, setSelectedClueId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 默认选中第一个已收集的线索
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && clues.length > 0 && !selectedClueId) {
|
||||||
|
const firstCollected = clues.find((c) => c.collected);
|
||||||
|
if (firstCollected) {
|
||||||
|
setSelectedClueId(firstCollected.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [visible, clues, selectedClueId]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const selectedClue = clues.find((c) => c.id === selectedClueId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* 蒙版背景 */}
|
||||||
|
{/* <div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
{/* 线索簿内容 */}
|
||||||
|
<div className="relative w-full h-full bg-cover bg-center bg-no-repeat">
|
||||||
|
{/* 背景图片 - 线索簿 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||||
|
style={{
|
||||||
|
backgroundImage: getBackgroundImage(
|
||||||
|
"sunzhongshan/reflection/详情线索簿背景"
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div className="relative z-10 w-full h-full flex px-40 pb-40">
|
||||||
|
{/* 左侧:线索列表 */}
|
||||||
|
<div className="grid grid-cols-2 gap-x-10 pt-36">
|
||||||
|
{clues.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 mt-8">暂无线索</div>
|
||||||
|
) : (
|
||||||
|
clues.map((clue) => {
|
||||||
|
const isSelected = clue.id === selectedClueId;
|
||||||
|
const isCollected = clue.collected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={clue.id}
|
||||||
|
// className={cn(
|
||||||
|
// "relative cursor-pointer transition-all duration-300",
|
||||||
|
// "hover:scale-105",
|
||||||
|
// isSelected && "scale-110 z-10",
|
||||||
|
//
|
||||||
|
// )}
|
||||||
|
onClick={() => {
|
||||||
|
if (isCollected) {
|
||||||
|
setSelectedClueId(clue.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 线索缩略图 - 带倾斜效果 */}
|
||||||
|
<Image
|
||||||
|
src={
|
||||||
|
isCollected
|
||||||
|
? getClueImage(clue.id)
|
||||||
|
: getClueImage(clue.id)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"hover:scale-110 hover:brightness-115 cursor-pointer transition-all duration-300",
|
||||||
|
isSelected &&
|
||||||
|
"scale-115 brightness-115 z-10 grayscale-0",
|
||||||
|
!isCollected &&
|
||||||
|
"opacity-50 grayscale cursor-not-allowed hover:scale-100 hover:brightness-100"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:线索详情或对话 */}
|
||||||
|
<div className="flex-1 px-10 flex items-center justify-center pt-58">
|
||||||
|
{selectedClue && selectedClue.collected ? (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||||
|
{/* 显示线索详情图片 */}
|
||||||
|
<div className="relative w-full h-full text-center">
|
||||||
|
<Image
|
||||||
|
src={getClueDetailImage(selectedClue.id)}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500 text-4xl">
|
||||||
|
{selectedClue ? "该线索尚未收集" : "请选择一个线索查看详情"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 关闭按钮 */}
|
||||||
|
<Image
|
||||||
|
className="absolute top-0 right-0 z-10"
|
||||||
|
src={getStaticImage(`sunzhongshan/reflection/关闭`)}
|
||||||
|
button
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Image } from "@/components";
|
||||||
|
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface ClueHint {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position: {
|
||||||
|
top: string;
|
||||||
|
left: string;
|
||||||
|
width: string;
|
||||||
|
height: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClueHintModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
backgroundImage: string;
|
||||||
|
clues: ClueHint[];
|
||||||
|
onClose: () => void;
|
||||||
|
sourceContainerRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 线索提示窗组件 - 显示所有线索位置
|
||||||
|
*/
|
||||||
|
export const ClueHintModal = ({
|
||||||
|
visible,
|
||||||
|
backgroundImage,
|
||||||
|
clues,
|
||||||
|
onClose,
|
||||||
|
sourceContainerRef,
|
||||||
|
}: ClueHintModalProps) => {
|
||||||
|
const modalContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const backgroundRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const [size, setSize] = useState({ width: "100%", height: "100%" });
|
||||||
|
|
||||||
|
// 计算缩放比例和偏移
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!visible ||
|
||||||
|
!sourceContainerRef?.current ||
|
||||||
|
!modalContainerRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateScale = () => {
|
||||||
|
const sourceRect = sourceContainerRef.current!.getBoundingClientRect();
|
||||||
|
const modalRect = modalContainerRef.current!.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (sourceRect.width === 0 || sourceRect.height === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算宽高缩放比例,取较小的值以确保完全显示
|
||||||
|
const scaleX = modalRect.width / sourceRect.width;
|
||||||
|
const scaleY = modalRect.height / sourceRect.height;
|
||||||
|
const newScale = Math.min(scaleX, scaleY);
|
||||||
|
|
||||||
|
setScale(newScale);
|
||||||
|
setSize({
|
||||||
|
width: `${sourceRect.width}px`,
|
||||||
|
height: `${sourceRect.height}px`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 延迟计算以确保 DOM 已渲染
|
||||||
|
const timer = setTimeout(calculateScale, 0);
|
||||||
|
calculateScale();
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
window.addEventListener("resize", calculateScale);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
window.removeEventListener("resize", calculateScale);
|
||||||
|
};
|
||||||
|
}, [visible, sourceContainerRef]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-50 flex items-center justify-center"
|
||||||
|
ref={modalContainerRef}
|
||||||
|
>
|
||||||
|
{/* 蒙版背景 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 背景图片容器 - 使用缩放匹配源容器尺寸并居中 */}
|
||||||
|
<div
|
||||||
|
ref={backgroundRef}
|
||||||
|
className="relative bg-cover bg-center bg-no-repeat"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${backgroundImage})`,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "center center",
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
border: "40px solid transparent",
|
||||||
|
borderImageSource: getBackgroundImage("sunzhongshan/reflection/框"),
|
||||||
|
borderImageSlice: "40 fill",
|
||||||
|
borderImageRepeat: "stretch round",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 线索标记 */}
|
||||||
|
{clues.map(
|
||||||
|
(clue) =>
|
||||||
|
clue.position.width !== "0%" && (
|
||||||
|
<div
|
||||||
|
key={clue.id}
|
||||||
|
className="absolute border-4 border-yellow-400 bg-yellow-400/20 rounded-lg flex items-center justify-center animate-pulse"
|
||||||
|
style={{
|
||||||
|
top: clue.position.top,
|
||||||
|
left: clue.position.left,
|
||||||
|
width: clue.position.width,
|
||||||
|
height: clue.position.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-yellow-400 font-bold text-xl md:text-2xl bg-black/70 px-3 py-2 rounded shadow-lg whitespace-nowrap">
|
||||||
|
{clue.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 关闭按钮 */}
|
||||||
|
<Image
|
||||||
|
className="absolute top-0 right-0 z-10"
|
||||||
|
src={getStaticImage(`sunzhongshan/reflection/关闭`)}
|
||||||
|
button
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { ClueHintModal } from "./ClueHintModal";
|
||||||
|
export type { ClueHintModalProps, ClueHint } from "./ClueHintModal";
|
||||||
|
|
||||||
|
export { ClueBookModal } from "./ClueBookModal";
|
||||||
|
export type { ClueBookModalProps } from "./ClueBookModal";
|
||||||
@@ -71,3 +71,11 @@ export type {
|
|||||||
// Game components
|
// Game components
|
||||||
export { BambooPuzzleGame } from "./game";
|
export { BambooPuzzleGame } from "./game";
|
||||||
export type { BambooPuzzleGameProps } from "./game";
|
export type { BambooPuzzleGameProps } from "./game";
|
||||||
|
|
||||||
|
// Clue components
|
||||||
|
export { ClueHintModal, ClueBookModal } from "./clue";
|
||||||
|
export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from "./clue";
|
||||||
|
|
||||||
|
// Animation components
|
||||||
|
export { flyToAnimation } from "./animation";
|
||||||
|
export type { FlyToAnimationOptions, FlyToPosition } from "./animation";
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { Image } from "@/components";
|
|
||||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
|
||||||
|
|
||||||
const starName = "sunzhongshan";
|
|
||||||
|
|
||||||
const SunzhongshanReflection = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { moduleName } = useParams<{
|
|
||||||
moduleName: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-full h-full bg-cover bg-center bg-no-repeat"
|
|
||||||
style={{
|
|
||||||
backgroundImage: getBackgroundImage(`${starName}/reflection/底图`),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="absolute right-5 top-5"
|
|
||||||
src={getStaticImage("components/离开")}
|
|
||||||
button
|
|
||||||
onClick={() => {
|
|
||||||
if (moduleName && starName) {
|
|
||||||
navigate(`/${moduleName}/${starName}/home`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-white text-4xl">孙中山故居-体悟模块(待完善)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SunzhongshanReflection;
|
|
||||||
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Image } from "@/components";
|
||||||
|
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||||
|
|
||||||
|
const starName = "sunzhongshan";
|
||||||
|
|
||||||
|
const SunzhongshanReflection = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { moduleName } = useParams<{
|
||||||
|
moduleName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [selectedScene, setSelectedScene] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleSceneClick = (sceneNumber: number) => {
|
||||||
|
setSelectedScene(sceneNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartExplore = () => {
|
||||||
|
if (selectedScene && moduleName && starName) {
|
||||||
|
navigate(`/${moduleName}/${starName}/reflection${selectedScene}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full h-full bg-cover bg-center bg-no-repeat relative"
|
||||||
|
style={{
|
||||||
|
backgroundImage: getBackgroundImage(`${starName}/reflection/home/背景`),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 关闭按钮 */}
|
||||||
|
<Image
|
||||||
|
className="absolute right-0 top-0 z-10"
|
||||||
|
src={getStaticImage(`${starName}/reflection/关闭`)}
|
||||||
|
button
|
||||||
|
onClick={() => {
|
||||||
|
if (moduleName && starName) {
|
||||||
|
navigate(`/${moduleName}/${starName}/home`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 场景选择区域 */}
|
||||||
|
<div className="absolute top-[46%] left-1/2 -translate-x-1/2 -translate-y-1/2 w-full flex items-center justify-center px-26">
|
||||||
|
{/* 场景一 */}
|
||||||
|
<div
|
||||||
|
className={`relative cursor-pointer transition-all duration-300 ${
|
||||||
|
selectedScene === 1 ? "z-30" : "z-10"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
transform: `rotate(0deg) ${
|
||||||
|
selectedScene === 1 ? "scale(1.15)" : "scale(1)"
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
onClick={() => handleSceneClick(1)}
|
||||||
|
>
|
||||||
|
<div className="p-3">
|
||||||
|
<Image src={getStaticImage(`${starName}/reflection/home/场景1`)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 场景二 */}
|
||||||
|
<div
|
||||||
|
className={`relative cursor-pointer transition-all duration-300 ${
|
||||||
|
selectedScene === 2 ? "z-30" : "z-20"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
transform: `rotate(0deg) ${
|
||||||
|
selectedScene === 2 ? "scale(1.15)" : "scale(1)"
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
onClick={() => handleSceneClick(2)}
|
||||||
|
>
|
||||||
|
<div className="p-3">
|
||||||
|
<Image src={getStaticImage(`${starName}/reflection/home/场景2`)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 场景三 */}
|
||||||
|
<div
|
||||||
|
className={`relative cursor-pointer transition-all duration-300 ${
|
||||||
|
selectedScene === 3 ? "z-30" : "z-10"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
transform: `rotate(0deg) ${
|
||||||
|
selectedScene === 3 ? "scale(1.15)" : "scale(1)"
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
onClick={() => handleSceneClick(3)}
|
||||||
|
>
|
||||||
|
<div className="p-3">
|
||||||
|
<Image src={getStaticImage(`${starName}/reflection/home/场景3`)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 开始探索按钮 */}
|
||||||
|
<div className="absolute bottom-30 left-1/2 -translate-x-1/2 z-10">
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-300 ${
|
||||||
|
selectedScene
|
||||||
|
? "opacity-100 cursor-pointer hover:scale-105"
|
||||||
|
: "opacity-50 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
onClick={selectedScene ? handleStartExplore : undefined}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={getStaticImage(`${starName}/reflection/home/按钮`)}
|
||||||
|
button={!!selectedScene}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SunzhongshanReflection;
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
ClueHintModal,
|
||||||
|
ClueBookModal,
|
||||||
|
flyToAnimation,
|
||||||
|
} from "@/components";
|
||||||
|
import { SuccessAlert } from "@/components/layout";
|
||||||
|
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||||
|
import { useClueStore } from "@/store/clueStore";
|
||||||
|
import type { ClueHint } from "@/components/clue";
|
||||||
|
|
||||||
|
const starName = "sunzhongshan";
|
||||||
|
const sceneId = `${starName}/reflection/scene1`;
|
||||||
|
|
||||||
|
// 线索配置 - 根据实际图片调整位置
|
||||||
|
const clueConfigs: ClueHint[] = [
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue1`,
|
||||||
|
name: "线索1",
|
||||||
|
position: {
|
||||||
|
top: "75%",
|
||||||
|
left: "95.5%",
|
||||||
|
width: "0%",
|
||||||
|
height: "0%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue2`,
|
||||||
|
name: "线索2",
|
||||||
|
position: {
|
||||||
|
top: "2%",
|
||||||
|
left: "34%",
|
||||||
|
width: "56%",
|
||||||
|
height: "50%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue3`,
|
||||||
|
name: "线索3",
|
||||||
|
position: {
|
||||||
|
top: "54%",
|
||||||
|
left: "20%",
|
||||||
|
width: "7%",
|
||||||
|
height: "34%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue4`,
|
||||||
|
name: "线索4",
|
||||||
|
position: {
|
||||||
|
top: "53%",
|
||||||
|
left: "48.5%",
|
||||||
|
width: "7%",
|
||||||
|
height: "34%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SunzhongshanReflectionScene1 = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { moduleName } = useParams<{
|
||||||
|
moduleName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [showClueHint, setShowClueHint] = useState(false);
|
||||||
|
const [showClueBook, setShowClueBook] = useState(false);
|
||||||
|
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||||
|
const [successAlertContent, setSuccessAlertContent] = useState("");
|
||||||
|
|
||||||
|
const { collectClue, isClueCollected } = useClueStore();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const clueBookButtonRef = useRef<HTMLDivElement>(null);
|
||||||
|
// 为每个线索创建 ref
|
||||||
|
const clueRefs = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取或创建线索的 ref
|
||||||
|
const getClueRef = (
|
||||||
|
clueId: string
|
||||||
|
): React.RefObject<HTMLDivElement | null> => {
|
||||||
|
if (!clueRefs.current.has(clueId)) {
|
||||||
|
clueRefs.current.set(clueId, React.createRef<HTMLDivElement>());
|
||||||
|
}
|
||||||
|
return clueRefs.current.get(clueId)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理线索点击
|
||||||
|
const handleClueClick = (clueId: string, clueName: string) => {
|
||||||
|
// 如果已收集,不重复收集
|
||||||
|
if (isClueCollected(clueId)) {
|
||||||
|
setSuccessAlertContent("该线索已收集");
|
||||||
|
setShowSuccessAlert(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集线索
|
||||||
|
collectClue(clueId, clueName);
|
||||||
|
|
||||||
|
// 获取线索元素的 ref
|
||||||
|
const clueRef = getClueRef(clueId);
|
||||||
|
|
||||||
|
// 使用函数式 API 触发飞行动画
|
||||||
|
if (clueRef.current && clueBookButtonRef.current) {
|
||||||
|
flyToAnimation({
|
||||||
|
fromRef: clueRef as React.RefObject<HTMLElement>,
|
||||||
|
toRef: clueBookButtonRef as React.RefObject<HTMLElement>,
|
||||||
|
targetPosition: "center",
|
||||||
|
duration: 1200,
|
||||||
|
onComplete: () => {
|
||||||
|
// 动画完成后的回调
|
||||||
|
setSuccessAlertContent("恭喜您,获得该线索!");
|
||||||
|
setShowSuccessAlert(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理背包点击 - 先收集线索1,然后打开线索提示框
|
||||||
|
const handleBackpackClick = () => {
|
||||||
|
const clue1Id = `${sceneId}/clue1`;
|
||||||
|
const clue1Name = "线索1";
|
||||||
|
|
||||||
|
// 如果线索1已收集,直接打开线索提示框
|
||||||
|
if (isClueCollected(clue1Id)) {
|
||||||
|
setShowClueHint(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集线索1
|
||||||
|
collectClue(clue1Id, clue1Name);
|
||||||
|
|
||||||
|
// 获取线索1的 ref
|
||||||
|
const clue1Ref = getClueRef(clue1Id);
|
||||||
|
|
||||||
|
// 执行飞行动画
|
||||||
|
if (clue1Ref.current && clueBookButtonRef.current) {
|
||||||
|
flyToAnimation({
|
||||||
|
fromRef: clue1Ref as React.RefObject<HTMLElement>,
|
||||||
|
toRef: clueBookButtonRef as React.RefObject<HTMLElement>,
|
||||||
|
targetPosition: "center",
|
||||||
|
duration: 1200,
|
||||||
|
onComplete: () => {
|
||||||
|
// 动画完成后,打开线索提示框
|
||||||
|
// setShowClueHint(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果 ref 不可用,直接打开线索提示框
|
||||||
|
setShowClueHint(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取线索图片
|
||||||
|
const getClueImage = (clueId: string) => {
|
||||||
|
const clueNumber = clueId.split("clue")[1];
|
||||||
|
return getStaticImage(`${sceneId}/线索${clueNumber}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取线索详情图片
|
||||||
|
const getClueDetailImage = (clueId: string) => {
|
||||||
|
const clueNumber = clueId.split("clue")[1];
|
||||||
|
return getStaticImage(`${sceneId}/线索${clueNumber}-详情`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化线索(如果还没有)
|
||||||
|
useEffect(() => {
|
||||||
|
clueConfigs.forEach((clue) => {
|
||||||
|
if (!isClueCollected(clue.id)) {
|
||||||
|
// 不自动收集,只是确保线索存在
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isClueCollected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full bg-cover bg-center bg-no-repeat relative"
|
||||||
|
style={{
|
||||||
|
backgroundImage: getBackgroundImage(`${starName}/reflection/scene1/bg`),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 关闭按钮 */}
|
||||||
|
<Image
|
||||||
|
className="absolute right-0 top-0 z-10"
|
||||||
|
src={getStaticImage(`${starName}/reflection/关闭`)}
|
||||||
|
button
|
||||||
|
onClick={() => {
|
||||||
|
if (moduleName && starName) {
|
||||||
|
navigate(`/${moduleName}/${starName}/reflection`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 线索点击区域 */}
|
||||||
|
{clueConfigs.map((clue) => {
|
||||||
|
const isCollected = isClueCollected(clue.id);
|
||||||
|
const clueRef = getClueRef(clue.id);
|
||||||
|
return (
|
||||||
|
<div key={clue.id}>
|
||||||
|
{/* 线索元素(用于克隆和飞行) */}
|
||||||
|
<div
|
||||||
|
ref={clueRef}
|
||||||
|
className="absolute pointer-events-none opacity-0"
|
||||||
|
style={{
|
||||||
|
top: clue.position.top,
|
||||||
|
left: clue.position.left,
|
||||||
|
width: clue.position.width,
|
||||||
|
height: clue.position.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Image
|
||||||
|
src={getClueImage(clue.id)}
|
||||||
|
className="w-20 h-20 border-2 border-yellow-400 rounded shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="absolute -bottom-6 left-1/2 -translate-x-1/2 bg-yellow-400 text-black px-2 py-1 rounded text-xs font-bold whitespace-nowrap">
|
||||||
|
{clue.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 点击区域 */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute cursor-pointer transition-all duration-200
|
||||||
|
${
|
||||||
|
isCollected
|
||||||
|
? "opacity-30 hover:opacity-50"
|
||||||
|
: "opacity-0 hover:opacity-20"
|
||||||
|
}
|
||||||
|
hover:bg-yellow-400/20
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
top: clue.position.top,
|
||||||
|
left: clue.position.left,
|
||||||
|
width: clue.position.width,
|
||||||
|
height: clue.position.height,
|
||||||
|
}}
|
||||||
|
onClick={() => handleClueClick(clue.id, clue.name)}
|
||||||
|
title={
|
||||||
|
isCollected ? `${clue.name}(已收集)` : `点击收集${clue.name}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 右下角-功能处 */}
|
||||||
|
<div className="absolute right-6 bottom-12 flex flex-col items-center gap-4 z-10">
|
||||||
|
<Image
|
||||||
|
src={getStaticImage(`${starName}/reflection/背包`)}
|
||||||
|
button
|
||||||
|
onClick={handleBackpackClick}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={clueBookButtonRef}
|
||||||
|
className="relative"
|
||||||
|
data-clue-book-button="true"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={getStaticImage(`${starName}/reflection/线索簿`)}
|
||||||
|
button
|
||||||
|
onClick={() => setShowClueBook(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 线索提示窗 */}
|
||||||
|
<ClueHintModal
|
||||||
|
visible={showClueHint}
|
||||||
|
backgroundImage={getStaticImage(`${starName}/reflection/scene1/bg`)}
|
||||||
|
clues={clueConfigs}
|
||||||
|
onClose={() => setShowClueHint(false)}
|
||||||
|
sourceContainerRef={containerRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 线索簿窗口 */}
|
||||||
|
<ClueBookModal
|
||||||
|
visible={showClueBook}
|
||||||
|
clues={clueConfigs.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
collected: isClueCollected(c.id),
|
||||||
|
}))}
|
||||||
|
onClose={() => setShowClueBook(false)}
|
||||||
|
getClueImage={getClueImage}
|
||||||
|
getClueDetailImage={getClueDetailImage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 成功提示弹窗 */}
|
||||||
|
<SuccessAlert
|
||||||
|
visible={showSuccessAlert}
|
||||||
|
title="恭喜你!"
|
||||||
|
content={successAlertContent}
|
||||||
|
onContinue={() => {
|
||||||
|
setShowSuccessAlert(false);
|
||||||
|
setSuccessAlertContent("");
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSuccessAlert(false);
|
||||||
|
setSuccessAlertContent("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SunzhongshanReflectionScene1;
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
ClueHintModal,
|
||||||
|
ClueBookModal,
|
||||||
|
flyToAnimation,
|
||||||
|
} from "@/components";
|
||||||
|
import { SuccessAlert } from "@/components/layout";
|
||||||
|
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||||
|
import { useClueStore } from "@/store/clueStore";
|
||||||
|
import type { ClueHint } from "@/components/clue";
|
||||||
|
|
||||||
|
const starName = "sunzhongshan";
|
||||||
|
const sceneId = `${starName}/reflection/scene2`;
|
||||||
|
|
||||||
|
// 线索配置 - 根据实际图片调整位置
|
||||||
|
const clueConfigs: ClueHint[] = [
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue1`,
|
||||||
|
name: "线索1",
|
||||||
|
position: {
|
||||||
|
top: "75%",
|
||||||
|
left: "95.5%",
|
||||||
|
width: "0%",
|
||||||
|
height: "0%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue2`,
|
||||||
|
name: "线索2",
|
||||||
|
position: {
|
||||||
|
top: "42%",
|
||||||
|
left: "26%",
|
||||||
|
width: "18%",
|
||||||
|
height: "22%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue3`,
|
||||||
|
name: "线索3",
|
||||||
|
position: {
|
||||||
|
top: "30%",
|
||||||
|
left: "6%",
|
||||||
|
width: "10%",
|
||||||
|
height: "22%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue4`,
|
||||||
|
name: "线索4",
|
||||||
|
position: {
|
||||||
|
top: "67%",
|
||||||
|
left: "8%",
|
||||||
|
width: "9%",
|
||||||
|
height: "7%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue5`,
|
||||||
|
name: "线索5",
|
||||||
|
position: {
|
||||||
|
top: "60%",
|
||||||
|
left: "44%",
|
||||||
|
width: "42%",
|
||||||
|
height: "30%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SunzhongshanReflectionScene2 = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { moduleName } = useParams<{
|
||||||
|
moduleName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [showClueHint, setShowClueHint] = useState(false);
|
||||||
|
const [showClueBook, setShowClueBook] = useState(false);
|
||||||
|
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||||
|
const [successAlertContent, setSuccessAlertContent] = useState("");
|
||||||
|
|
||||||
|
const { collectClue, isClueCollected } = useClueStore();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const clueBookButtonRef = useRef<HTMLDivElement>(null);
|
||||||
|
// 为每个线索创建 ref
|
||||||
|
const clueRefs = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取或创建线索的 ref
|
||||||
|
const getClueRef = (
|
||||||
|
clueId: string
|
||||||
|
): React.RefObject<HTMLDivElement | null> => {
|
||||||
|
if (!clueRefs.current.has(clueId)) {
|
||||||
|
clueRefs.current.set(clueId, React.createRef<HTMLDivElement>());
|
||||||
|
}
|
||||||
|
return clueRefs.current.get(clueId)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理线索点击
|
||||||
|
const handleClueClick = (clueId: string, clueName: string) => {
|
||||||
|
// 如果已收集,不重复收集
|
||||||
|
if (isClueCollected(clueId)) {
|
||||||
|
setSuccessAlertContent("该线索已收集");
|
||||||
|
setShowSuccessAlert(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集线索
|
||||||
|
collectClue(clueId, clueName);
|
||||||
|
|
||||||
|
// 获取线索元素的 ref
|
||||||
|
const clueRef = getClueRef(clueId);
|
||||||
|
|
||||||
|
// 使用函数式 API 触发飞行动画
|
||||||
|
if (clueRef.current && clueBookButtonRef.current) {
|
||||||
|
flyToAnimation({
|
||||||
|
fromRef: clueRef as React.RefObject<HTMLElement>,
|
||||||
|
toRef: clueBookButtonRef as React.RefObject<HTMLElement>,
|
||||||
|
targetPosition: "center",
|
||||||
|
duration: 1200,
|
||||||
|
onComplete: () => {
|
||||||
|
// 动画完成后的回调
|
||||||
|
setSuccessAlertContent("恭喜您,获得该线索!");
|
||||||
|
setShowSuccessAlert(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理背包点击 - 先收集线索1,然后打开线索提示框
|
||||||
|
const handleBackpackClick = () => {
|
||||||
|
const clue1Id = `${sceneId}/clue1`;
|
||||||
|
const clue1Name = "线索1";
|
||||||
|
|
||||||
|
// 如果线索1已收集,直接打开线索提示框
|
||||||
|
if (isClueCollected(clue1Id)) {
|
||||||
|
setShowClueHint(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集线索1
|
||||||
|
collectClue(clue1Id, clue1Name);
|
||||||
|
|
||||||
|
// 获取线索1的 ref
|
||||||
|
const clue1Ref = getClueRef(clue1Id);
|
||||||
|
|
||||||
|
// 执行飞行动画
|
||||||
|
if (clue1Ref.current && clueBookButtonRef.current) {
|
||||||
|
flyToAnimation({
|
||||||
|
fromRef: clue1Ref as React.RefObject<HTMLElement>,
|
||||||
|
toRef: clueBookButtonRef as React.RefObject<HTMLElement>,
|
||||||
|
targetPosition: "center",
|
||||||
|
duration: 1200,
|
||||||
|
onComplete: () => {
|
||||||
|
// 动画完成后,打开线索提示框
|
||||||
|
setShowClueHint(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果 ref 不可用,直接打开线索提示框
|
||||||
|
setShowClueHint(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取线索图片
|
||||||
|
const getClueImage = (clueId: string) => {
|
||||||
|
const clueNumber = clueId.split("clue")[1];
|
||||||
|
return getStaticImage(`${sceneId}/线索${clueNumber}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取线索详情图片
|
||||||
|
const getClueDetailImage = (clueId: string) => {
|
||||||
|
const clueNumber = clueId.split("clue")[1];
|
||||||
|
// 线索3没有详情图片,返回线索图片
|
||||||
|
if (clueNumber === "3") {
|
||||||
|
return getStaticImage(`${sceneId}/线索${clueNumber}`);
|
||||||
|
}
|
||||||
|
return getStaticImage(`${sceneId}/线索${clueNumber}-详情`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化线索(如果还没有)
|
||||||
|
useEffect(() => {
|
||||||
|
clueConfigs.forEach((clue) => {
|
||||||
|
if (!isClueCollected(clue.id)) {
|
||||||
|
// 不自动收集,只是确保线索存在
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isClueCollected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full bg-cover bg-center bg-no-repeat relative"
|
||||||
|
style={{
|
||||||
|
backgroundImage: getBackgroundImage(`${starName}/reflection/scene2/bg`),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 关闭按钮 */}
|
||||||
|
<Image
|
||||||
|
className="absolute right-0 top-0 z-10"
|
||||||
|
src={getStaticImage(`${starName}/reflection/关闭`)}
|
||||||
|
button
|
||||||
|
onClick={() => {
|
||||||
|
if (moduleName && starName) {
|
||||||
|
navigate(`/${moduleName}/${starName}/reflection`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 线索点击区域 */}
|
||||||
|
{clueConfigs.map((clue) => {
|
||||||
|
const isCollected = isClueCollected(clue.id);
|
||||||
|
const clueRef = getClueRef(clue.id);
|
||||||
|
return (
|
||||||
|
<div key={clue.id}>
|
||||||
|
{/* 线索元素(用于克隆和飞行) */}
|
||||||
|
<div
|
||||||
|
ref={clueRef}
|
||||||
|
className="absolute pointer-events-none opacity-0"
|
||||||
|
style={{
|
||||||
|
top: clue.position.top,
|
||||||
|
left: clue.position.left,
|
||||||
|
width: clue.position.width,
|
||||||
|
height: clue.position.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Image
|
||||||
|
src={getClueImage(clue.id)}
|
||||||
|
className="w-20 h-20 border-2 border-yellow-400 rounded shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="absolute -bottom-6 left-1/2 -translate-x-1/2 bg-yellow-400 text-black px-2 py-1 rounded text-xs font-bold whitespace-nowrap">
|
||||||
|
{clue.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 点击区域 */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute cursor-pointer transition-all duration-200
|
||||||
|
${
|
||||||
|
isCollected
|
||||||
|
? "opacity-30 hover:opacity-50"
|
||||||
|
: "opacity-0 hover:opacity-20"
|
||||||
|
}
|
||||||
|
hover:bg-yellow-400/20
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
top: clue.position.top,
|
||||||
|
left: clue.position.left,
|
||||||
|
width: clue.position.width,
|
||||||
|
height: clue.position.height,
|
||||||
|
}}
|
||||||
|
onClick={() => handleClueClick(clue.id, clue.name)}
|
||||||
|
title={
|
||||||
|
isCollected ? `${clue.name}(已收集)` : `点击收集${clue.name}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 右下角-功能处 */}
|
||||||
|
<div className="absolute right-6 bottom-12 flex flex-col items-center gap-4 z-10">
|
||||||
|
<Image
|
||||||
|
src={getStaticImage(`${starName}/reflection/背包`)}
|
||||||
|
button
|
||||||
|
onClick={handleBackpackClick}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={clueBookButtonRef}
|
||||||
|
className="relative"
|
||||||
|
data-clue-book-button="true"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={getStaticImage(`${starName}/reflection/线索簿`)}
|
||||||
|
button
|
||||||
|
onClick={() => setShowClueBook(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 线索提示窗 */}
|
||||||
|
<ClueHintModal
|
||||||
|
visible={showClueHint}
|
||||||
|
backgroundImage={getStaticImage(`${starName}/reflection/scene2/bg`)}
|
||||||
|
clues={clueConfigs}
|
||||||
|
onClose={() => setShowClueHint(false)}
|
||||||
|
sourceContainerRef={containerRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 线索簿窗口 */}
|
||||||
|
<ClueBookModal
|
||||||
|
visible={showClueBook}
|
||||||
|
clues={clueConfigs.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
collected: isClueCollected(c.id),
|
||||||
|
}))}
|
||||||
|
onClose={() => setShowClueBook(false)}
|
||||||
|
getClueImage={getClueImage}
|
||||||
|
getClueDetailImage={getClueDetailImage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 成功提示弹窗 */}
|
||||||
|
<SuccessAlert
|
||||||
|
visible={showSuccessAlert}
|
||||||
|
title="恭喜你!"
|
||||||
|
content={successAlertContent}
|
||||||
|
onContinue={() => {
|
||||||
|
setShowSuccessAlert(false);
|
||||||
|
setSuccessAlertContent("");
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSuccessAlert(false);
|
||||||
|
setSuccessAlertContent("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SunzhongshanReflectionScene2;
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
ClueHintModal,
|
||||||
|
ClueBookModal,
|
||||||
|
flyToAnimation,
|
||||||
|
} from "@/components";
|
||||||
|
import { SuccessAlert } from "@/components/layout";
|
||||||
|
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||||
|
import { useClueStore } from "@/store/clueStore";
|
||||||
|
import type { ClueHint } from "@/components/clue";
|
||||||
|
|
||||||
|
const starName = "sunzhongshan";
|
||||||
|
const sceneId = `${starName}/reflection/scene3`;
|
||||||
|
|
||||||
|
// 线索配置 - 根据实际图片调整位置
|
||||||
|
const clueConfigs: ClueHint[] = [
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue1`,
|
||||||
|
name: "线索1",
|
||||||
|
position: {
|
||||||
|
top: "20%",
|
||||||
|
left: "65%",
|
||||||
|
width: "0%",
|
||||||
|
height: "0%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue2`,
|
||||||
|
name: "线索2",
|
||||||
|
position: {
|
||||||
|
top: "26%",
|
||||||
|
left: "43%",
|
||||||
|
width: "40%",
|
||||||
|
height: "26%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue3`,
|
||||||
|
name: "线索3",
|
||||||
|
position: {
|
||||||
|
top: "44%",
|
||||||
|
left: "10%",
|
||||||
|
width: "85%",
|
||||||
|
height: "50%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${sceneId}/clue4`,
|
||||||
|
name: "线索4",
|
||||||
|
position: {
|
||||||
|
top: "30%",
|
||||||
|
left: "40%",
|
||||||
|
width: "8%",
|
||||||
|
height: "12%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SunzhongshanReflectionScene3 = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { moduleName } = useParams<{
|
||||||
|
moduleName: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [showClueHint, setShowClueHint] = useState(false);
|
||||||
|
const [showClueBook, setShowClueBook] = useState(false);
|
||||||
|
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||||
|
const [successAlertContent, setSuccessAlertContent] = useState("");
|
||||||
|
|
||||||
|
const { collectClue, isClueCollected } = useClueStore();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const clueBookButtonRef = useRef<HTMLDivElement>(null);
|
||||||
|
// 为每个线索创建 ref
|
||||||
|
const clueRefs = useRef<Map<string, React.RefObject<HTMLDivElement | null>>>(
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取或创建线索的 ref
|
||||||
|
const getClueRef = (
|
||||||
|
clueId: string
|
||||||
|
): React.RefObject<HTMLDivElement | null> => {
|
||||||
|
if (!clueRefs.current.has(clueId)) {
|
||||||
|
clueRefs.current.set(clueId, React.createRef<HTMLDivElement>());
|
||||||
|
}
|
||||||
|
return clueRefs.current.get(clueId)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理线索点击
|
||||||
|
const handleClueClick = (clueId: string, clueName: string) => {
|
||||||
|
// 如果已收集,不重复收集
|
||||||
|
if (isClueCollected(clueId)) {
|
||||||
|
setSuccessAlertContent("该线索已收集");
|
||||||
|
setShowSuccessAlert(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集线索
|
||||||
|
collectClue(clueId, clueName);
|
||||||
|
|
||||||
|
// 获取线索元素的 ref
|
||||||
|
const clueRef = getClueRef(clueId);
|
||||||
|
|
||||||
|
// 使用函数式 API 触发飞行动画
|
||||||
|
if (clueRef.current && clueBookButtonRef.current) {
|
||||||
|
flyToAnimation({
|
||||||
|
fromRef: clueRef as React.RefObject<HTMLElement>,
|
||||||
|
toRef: clueBookButtonRef as React.RefObject<HTMLElement>,
|
||||||
|
targetPosition: "center",
|
||||||
|
duration: 1200,
|
||||||
|
onComplete: () => {
|
||||||
|
// 动画完成后的回调
|
||||||
|
setSuccessAlertContent("恭喜您,获得该线索!");
|
||||||
|
setShowSuccessAlert(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取线索图片
|
||||||
|
const getClueImage = (clueId: string) => {
|
||||||
|
const clueNumber = clueId.split("clue")[1];
|
||||||
|
return getStaticImage(`${sceneId}/线索${clueNumber}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取线索详情图片
|
||||||
|
const getClueDetailImage = (clueId: string) => {
|
||||||
|
const clueNumber = clueId.split("clue")[1];
|
||||||
|
return getStaticImage(`${sceneId}/线索${clueNumber}-详情`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化线索(如果还没有)
|
||||||
|
useEffect(() => {
|
||||||
|
clueConfigs.forEach((clue) => {
|
||||||
|
if (!isClueCollected(clue.id)) {
|
||||||
|
// 不自动收集,只是确保线索存在
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isClueCollected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full bg-cover bg-center bg-no-repeat relative"
|
||||||
|
style={{
|
||||||
|
backgroundImage: getBackgroundImage(`${starName}/reflection/scene3/bg`),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 关闭按钮 */}
|
||||||
|
<Image
|
||||||
|
className="absolute right-0 top-0 z-10"
|
||||||
|
src={getStaticImage(`${starName}/reflection/关闭`)}
|
||||||
|
button
|
||||||
|
onClick={() => {
|
||||||
|
if (moduleName && starName) {
|
||||||
|
navigate(`/${moduleName}/${starName}/reflection`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 线索点击区域 */}
|
||||||
|
{clueConfigs.map((clue) => {
|
||||||
|
const isCollected = isClueCollected(clue.id);
|
||||||
|
const clueRef = getClueRef(clue.id);
|
||||||
|
return (
|
||||||
|
<div key={clue.id}>
|
||||||
|
{/* 线索元素(用于克隆和飞行) */}
|
||||||
|
<div
|
||||||
|
ref={clueRef}
|
||||||
|
className="absolute pointer-events-none opacity-0"
|
||||||
|
style={{
|
||||||
|
top: clue.position.top,
|
||||||
|
left: clue.position.left,
|
||||||
|
width: clue.position.width,
|
||||||
|
height: clue.position.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Image
|
||||||
|
src={getClueImage(clue.id)}
|
||||||
|
className="w-20 h-20 border-2 border-yellow-400 rounded shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="absolute -bottom-6 left-1/2 -translate-x-1/2 bg-yellow-400 text-black px-2 py-1 rounded text-xs font-bold whitespace-nowrap">
|
||||||
|
{clue.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 点击区域 */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute cursor-pointer transition-all duration-200
|
||||||
|
${
|
||||||
|
isCollected
|
||||||
|
? "opacity-30 hover:opacity-50"
|
||||||
|
: "opacity-0 hover:opacity-20"
|
||||||
|
}
|
||||||
|
hover:bg-yellow-400/20
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
top: clue.position.top,
|
||||||
|
left: clue.position.left,
|
||||||
|
width: clue.position.width,
|
||||||
|
height: clue.position.height,
|
||||||
|
}}
|
||||||
|
onClick={() => handleClueClick(clue.id, clue.name)}
|
||||||
|
title={
|
||||||
|
isCollected ? `${clue.name}(已收集)` : `点击收集${clue.name}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 右下角-功能处 */}
|
||||||
|
<div className="absolute right-6 bottom-12 flex flex-col items-center gap-4 z-10">
|
||||||
|
<Image
|
||||||
|
src={getStaticImage(`${starName}/reflection/背包`)}
|
||||||
|
button
|
||||||
|
onClick={() => setShowClueHint(true)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={clueBookButtonRef}
|
||||||
|
className="relative"
|
||||||
|
data-clue-book-button="true"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={getStaticImage(`${starName}/reflection/线索簿`)}
|
||||||
|
button
|
||||||
|
onClick={() => setShowClueBook(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 线索提示窗 */}
|
||||||
|
<ClueHintModal
|
||||||
|
visible={showClueHint}
|
||||||
|
backgroundImage={getStaticImage(`${starName}/reflection/scene3/bg`)}
|
||||||
|
clues={clueConfigs}
|
||||||
|
onClose={() => setShowClueHint(false)}
|
||||||
|
sourceContainerRef={containerRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 线索簿窗口 */}
|
||||||
|
<ClueBookModal
|
||||||
|
visible={showClueBook}
|
||||||
|
clues={clueConfigs.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
collected: isClueCollected(c.id),
|
||||||
|
}))}
|
||||||
|
onClose={() => setShowClueBook(false)}
|
||||||
|
getClueImage={getClueImage}
|
||||||
|
getClueDetailImage={getClueDetailImage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 成功提示弹窗 */}
|
||||||
|
<SuccessAlert
|
||||||
|
visible={showSuccessAlert}
|
||||||
|
title="恭喜你!"
|
||||||
|
content={successAlertContent}
|
||||||
|
onContinue={() => {
|
||||||
|
setShowSuccessAlert(false);
|
||||||
|
setSuccessAlertContent("");
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSuccessAlert(false);
|
||||||
|
setSuccessAlertContent("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SunzhongshanReflectionScene3;
|
||||||
@@ -3,11 +3,30 @@ import { RouteObject } from "react-router-dom";
|
|||||||
import { ResidenceLayout } from "@/components/layout";
|
import { ResidenceLayout } from "@/components/layout";
|
||||||
|
|
||||||
const HomePage = lazy(() => import("@/pages/sunzhongshan/home"));
|
const HomePage = lazy(() => import("@/pages/sunzhongshan/home"));
|
||||||
const SunzhongshanPlace1 = lazy(() => import("@/pages/sunzhongshan/place/place1"));
|
const SunzhongshanPlace1 = lazy(
|
||||||
const SunzhongshanPlace2 = lazy(() => import("@/pages/sunzhongshan/place/place2"));
|
() => import("@/pages/sunzhongshan/place/place1")
|
||||||
const SunzhongshanPlace3 = lazy(() => import("@/pages/sunzhongshan/place/place3"));
|
);
|
||||||
const SunzhongshanPlace4 = lazy(() => import("@/pages/sunzhongshan/place/place4"));
|
const SunzhongshanPlace2 = lazy(
|
||||||
const SunzhongshanReflection = lazy(() => import("@/pages/sunzhongshan/reflection"));
|
() => import("@/pages/sunzhongshan/place/place2")
|
||||||
|
);
|
||||||
|
const SunzhongshanPlace3 = lazy(
|
||||||
|
() => import("@/pages/sunzhongshan/place/place3")
|
||||||
|
);
|
||||||
|
const SunzhongshanPlace4 = lazy(
|
||||||
|
() => import("@/pages/sunzhongshan/place/place4")
|
||||||
|
);
|
||||||
|
const SunzhongshanReflection = lazy(
|
||||||
|
() => import("@/pages/sunzhongshan/reflection/index")
|
||||||
|
);
|
||||||
|
const SunzhongshanReflectionScene1 = lazy(
|
||||||
|
() => import("@/pages/sunzhongshan/reflection/scene1")
|
||||||
|
);
|
||||||
|
const SunzhongshanReflectionScene2 = lazy(
|
||||||
|
() => import("@/pages/sunzhongshan/reflection/scene2")
|
||||||
|
);
|
||||||
|
const SunzhongshanReflectionScene3 = lazy(
|
||||||
|
() => import("@/pages/sunzhongshan/reflection/scene3")
|
||||||
|
);
|
||||||
const SunzhongshanInternalization = lazy(
|
const SunzhongshanInternalization = lazy(
|
||||||
() => import("@/pages/sunzhongshan/internalization")
|
() => import("@/pages/sunzhongshan/internalization")
|
||||||
);
|
);
|
||||||
@@ -65,6 +84,18 @@ export const SunzhongshanRoutes: RouteObject[] = [
|
|||||||
path: "reflection",
|
path: "reflection",
|
||||||
element: <SunzhongshanReflection />,
|
element: <SunzhongshanReflection />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "reflection1",
|
||||||
|
element: <SunzhongshanReflectionScene1 />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "reflection2",
|
||||||
|
element: <SunzhongshanReflectionScene2 />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "reflection3",
|
||||||
|
element: <SunzhongshanReflectionScene3 />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "internalization",
|
path: "internalization",
|
||||||
element: <SunzhongshanInternalization />,
|
element: <SunzhongshanInternalization />,
|
||||||
@@ -75,4 +106,3 @@ export const SunzhongshanRoutes: RouteObject[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export interface Clue {
|
||||||
|
id: string; // 格式: "sunzhongshan/reflection/scene1/clue1"
|
||||||
|
name: string; // 线索名称,如 "线索1"
|
||||||
|
collected: boolean; // 是否已收集
|
||||||
|
collectedAt?: number; // 收集时间戳
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClueStore {
|
||||||
|
clues: Clue[];
|
||||||
|
// 收集线索
|
||||||
|
collectClue: (id: string, name: string) => void;
|
||||||
|
// 检查线索是否已收集
|
||||||
|
isClueCollected: (id: string) => boolean;
|
||||||
|
// 获取指定场景的所有线索
|
||||||
|
getSceneClues: (sceneId: string) => Clue[];
|
||||||
|
// 重置指定场景的线索
|
||||||
|
resetSceneClues: (sceneId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useClueStore = create<ClueStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
clues: [],
|
||||||
|
|
||||||
|
collectClue: (id: string, name: string) => {
|
||||||
|
const { clues } = get();
|
||||||
|
const existingClue = clues.find((c) => c.id === id);
|
||||||
|
|
||||||
|
if (!existingClue) {
|
||||||
|
set({
|
||||||
|
clues: [
|
||||||
|
...clues,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
collected: true,
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else if (!existingClue.collected) {
|
||||||
|
set({
|
||||||
|
clues: clues.map((c) =>
|
||||||
|
c.id === id
|
||||||
|
? { ...c, collected: true, collectedAt: Date.now() }
|
||||||
|
: c
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isClueCollected: (id: string) => {
|
||||||
|
const { clues } = get();
|
||||||
|
return clues.some((c) => c.id === id && c.collected);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSceneClues: (sceneId: string) => {
|
||||||
|
const { clues } = get();
|
||||||
|
return clues.filter((c) => c.id.startsWith(sceneId));
|
||||||
|
},
|
||||||
|
|
||||||
|
resetSceneClues: (sceneId: string) => {
|
||||||
|
const { clues } = get();
|
||||||
|
set({
|
||||||
|
clues: clues.filter((c) => !c.id.startsWith(sceneId)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "clue-storage", // localStorage key
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||