diff --git a/src/assets/images/sunzhongshan/reflection/home/场景1.png b/src/assets/images/sunzhongshan/reflection/home/场景1.png new file mode 100644 index 0000000..86470a6 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/home/场景1.png differ diff --git a/src/assets/images/sunzhongshan/reflection/home/场景2.png b/src/assets/images/sunzhongshan/reflection/home/场景2.png new file mode 100644 index 0000000..560e670 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/home/场景2.png differ diff --git a/src/assets/images/sunzhongshan/reflection/home/场景3.png b/src/assets/images/sunzhongshan/reflection/home/场景3.png new file mode 100644 index 0000000..452719b Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/home/场景3.png differ diff --git a/src/assets/images/sunzhongshan/reflection/home/按钮.png b/src/assets/images/sunzhongshan/reflection/home/按钮.png new file mode 100644 index 0000000..159b95d Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/home/按钮.png differ diff --git a/src/assets/images/sunzhongshan/reflection/home/背景.png b/src/assets/images/sunzhongshan/reflection/home/背景.png new file mode 100644 index 0000000..3c5890e Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/home/背景.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/bg.png b/src/assets/images/sunzhongshan/reflection/scene1/bg.png new file mode 100644 index 0000000..5b31167 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene1/bg.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/线索1-详情.png b/src/assets/images/sunzhongshan/reflection/scene1/线索1-详情.png new file mode 100644 index 0000000..a35478b Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene1/线索1-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/线索1.png b/src/assets/images/sunzhongshan/reflection/scene1/线索1.png new file mode 100644 index 0000000..b888296 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene1/线索1.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/线索2-详情.png b/src/assets/images/sunzhongshan/reflection/scene1/线索2-详情.png new file mode 100644 index 0000000..aef99ff Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene1/线索2-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/线索2.png b/src/assets/images/sunzhongshan/reflection/scene1/线索2.png new file mode 100644 index 0000000..425813a Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene1/线索2.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/线索3-详情.png b/src/assets/images/sunzhongshan/reflection/scene1/线索3-详情.png new file mode 100644 index 0000000..c88078a Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene1/线索3-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/线索3.png b/src/assets/images/sunzhongshan/reflection/scene1/线索3.png new file mode 100644 index 0000000..87d71b0 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene1/线索3.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/线索4-详情.png b/src/assets/images/sunzhongshan/reflection/scene1/线索4-详情.png new file mode 100644 index 0000000..c699846 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene1/线索4-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/线索4.png b/src/assets/images/sunzhongshan/reflection/scene1/线索4.png new file mode 100644 index 0000000..e0524a7 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene1/线索4.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene1/背包背景.png b/src/assets/images/sunzhongshan/reflection/scene1/背包背景.png new file mode 100644 index 0000000..4e873ee Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene1/背包背景.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/bg.png b/src/assets/images/sunzhongshan/reflection/scene2/bg.png new file mode 100644 index 0000000..4224925 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene2/bg.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/线索1-详情.png b/src/assets/images/sunzhongshan/reflection/scene2/线索1-详情.png new file mode 100644 index 0000000..051be74 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene2/线索1-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/线索1.png b/src/assets/images/sunzhongshan/reflection/scene2/线索1.png new file mode 100644 index 0000000..b888296 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene2/线索1.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/线索2-详情.png b/src/assets/images/sunzhongshan/reflection/scene2/线索2-详情.png new file mode 100644 index 0000000..11a0d5c Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene2/线索2-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/线索2.png b/src/assets/images/sunzhongshan/reflection/scene2/线索2.png new file mode 100644 index 0000000..60e6f1e Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene2/线索2.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/线索3.png b/src/assets/images/sunzhongshan/reflection/scene2/线索3.png new file mode 100644 index 0000000..4529b53 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene2/线索3.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/线索4-详情.png b/src/assets/images/sunzhongshan/reflection/scene2/线索4-详情.png new file mode 100644 index 0000000..0ad24dc Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene2/线索4-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/线索4.png b/src/assets/images/sunzhongshan/reflection/scene2/线索4.png new file mode 100644 index 0000000..9dd3052 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene2/线索4.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/线索5-详情.png b/src/assets/images/sunzhongshan/reflection/scene2/线索5-详情.png new file mode 100644 index 0000000..3ca32b2 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene2/线索5-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/线索5.png b/src/assets/images/sunzhongshan/reflection/scene2/线索5.png new file mode 100644 index 0000000..7a1f83e Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene2/线索5.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene2/背包背景.png b/src/assets/images/sunzhongshan/reflection/scene2/背包背景.png new file mode 100644 index 0000000..0e6bd6f Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene2/背包背景.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/bg.png b/src/assets/images/sunzhongshan/reflection/scene3/bg.png new file mode 100644 index 0000000..3f4e6e6 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene3/bg.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/线索1-详情.png b/src/assets/images/sunzhongshan/reflection/scene3/线索1-详情.png new file mode 100644 index 0000000..1c61cd6 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene3/线索1-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/线索1.png b/src/assets/images/sunzhongshan/reflection/scene3/线索1.png new file mode 100644 index 0000000..0e7602c Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene3/线索1.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/线索2-详情.png b/src/assets/images/sunzhongshan/reflection/scene3/线索2-详情.png new file mode 100644 index 0000000..beba5c1 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene3/线索2-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/线索2.png b/src/assets/images/sunzhongshan/reflection/scene3/线索2.png new file mode 100644 index 0000000..2d13af1 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene3/线索2.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/线索3-详情.png b/src/assets/images/sunzhongshan/reflection/scene3/线索3-详情.png new file mode 100644 index 0000000..e173185 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene3/线索3-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/线索3.png b/src/assets/images/sunzhongshan/reflection/scene3/线索3.png new file mode 100644 index 0000000..9336b01 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene3/线索3.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/线索4-详情.png b/src/assets/images/sunzhongshan/reflection/scene3/线索4-详情.png new file mode 100644 index 0000000..315e485 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene3/线索4-详情.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/线索4.png b/src/assets/images/sunzhongshan/reflection/scene3/线索4.png new file mode 100644 index 0000000..d4432ed Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene3/线索4.png differ diff --git a/src/assets/images/sunzhongshan/reflection/scene3/背包背景.png b/src/assets/images/sunzhongshan/reflection/scene3/背包背景.png new file mode 100644 index 0000000..4a39f11 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/scene3/背包背景.png differ diff --git a/src/assets/images/sunzhongshan/reflection/关闭.png b/src/assets/images/sunzhongshan/reflection/关闭.png new file mode 100644 index 0000000..92084c4 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/关闭.png differ diff --git a/src/assets/images/sunzhongshan/reflection/框.png b/src/assets/images/sunzhongshan/reflection/框.png new file mode 100644 index 0000000..6a7966e Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/框.png differ diff --git a/src/assets/images/sunzhongshan/reflection/线索簿.png b/src/assets/images/sunzhongshan/reflection/线索簿.png new file mode 100644 index 0000000..d243772 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/线索簿.png differ diff --git a/src/assets/images/sunzhongshan/reflection/背包.png b/src/assets/images/sunzhongshan/reflection/背包.png new file mode 100644 index 0000000..ed67304 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/背包.png differ diff --git a/src/assets/images/sunzhongshan/reflection/详情线索簿背景.png b/src/assets/images/sunzhongshan/reflection/详情线索簿背景.png new file mode 100644 index 0000000..deb10f6 Binary files /dev/null and b/src/assets/images/sunzhongshan/reflection/详情线索簿背景.png differ diff --git a/src/components/animation/FlyToAnimationManager.tsx b/src/components/animation/FlyToAnimationManager.tsx new file mode 100644 index 0000000..115914d --- /dev/null +++ b/src/components/animation/FlyToAnimationManager.tsx @@ -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; + /** 目标元素的 ref */ + toRef: React.RefObject; + /** 目标位置(相对于目标元素) */ + 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 = 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) => ( + + ))} + + ); + } + + // 通知更新 + 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 ( + 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})` + } + > +
+ + ); +}; diff --git a/src/components/animation/flyToAnimation.ts b/src/components/animation/flyToAnimation.ts new file mode 100644 index 0000000..b49c99b --- /dev/null +++ b/src/components/animation/flyToAnimation.ts @@ -0,0 +1,23 @@ +import { flyToAnimationManager } from "./FlyToAnimationManager"; +import type { FlyToAnimationOptions } from "./FlyToAnimationManager"; + +/** + * 飞行动画函数 + * 将一个元素从起始位置飞向目标元素 + * + * @example + * ```tsx + * const fromRef = useRef(null); + * const toRef = useRef(null); + * + * flyToAnimation({ + * fromRef, + * toRef, + * targetPosition: "center", + * onComplete: () => console.log("动画完成"), + * }); + * ``` + */ +export const flyToAnimation = (options: FlyToAnimationOptions) => { + flyToAnimationManager.flyTo(options); +}; diff --git a/src/components/animation/index.ts b/src/components/animation/index.ts new file mode 100644 index 0000000..d063fbc --- /dev/null +++ b/src/components/animation/index.ts @@ -0,0 +1,4 @@ +// 函数式 API +export { flyToAnimation } from "./flyToAnimation"; +export type { FlyToAnimationOptions } from "./FlyToAnimationManager"; +export type { FlyToPosition } from "./FlyToAnimationManager"; diff --git a/src/components/clue/ClueBookModal.tsx b/src/components/clue/ClueBookModal.tsx new file mode 100644 index 0000000..abbdb57 --- /dev/null +++ b/src/components/clue/ClueBookModal.tsx @@ -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(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 ( +
+ {/* 蒙版背景 */} + {/*
*/} + + {/* 线索簿内容 */} +
+ {/* 背景图片 - 线索簿 */} +
+ + {/* 内容区域 */} +
+ {/* 左侧:线索列表 */} +
+ {clues.length === 0 ? ( +
暂无线索
+ ) : ( + clues.map((clue) => { + const isSelected = clue.id === selectedClueId; + const isCollected = clue.collected; + + return ( +
{ + if (isCollected) { + setSelectedClueId(clue.id); + } + }} + > + {/* 线索缩略图 - 带倾斜效果 */} + +
+ ); + }) + )} +
+ + {/* 右侧:线索详情或对话 */} +
+ {selectedClue && selectedClue.collected ? ( +
+ {/* 显示线索详情图片 */} +
+ +
+
+ ) : ( +
+ {selectedClue ? "该线索尚未收集" : "请选择一个线索查看详情"} +
+ )} +
+
+ + {/* 关闭按钮 */} + +
+
+ ); +}; diff --git a/src/components/clue/ClueHintModal.tsx b/src/components/clue/ClueHintModal.tsx new file mode 100644 index 0000000..45820da --- /dev/null +++ b/src/components/clue/ClueHintModal.tsx @@ -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; +} + +/** + * 线索提示窗组件 - 显示所有线索位置 + */ +export const ClueHintModal = ({ + visible, + backgroundImage, + clues, + onClose, + sourceContainerRef, +}: ClueHintModalProps) => { + const modalContainerRef = useRef(null); + const backgroundRef = useRef(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 ( +
+ {/* 蒙版背景 */} +
+ + {/* 背景图片容器 - 使用缩放匹配源容器尺寸并居中 */} +
+ {/* 线索标记 */} + {clues.map( + (clue) => + clue.position.width !== "0%" && ( +
+ + {clue.name} + +
+ ) + )} +
+ + {/* 关闭按钮 */} + +
+ ); +}; diff --git a/src/components/clue/index.ts b/src/components/clue/index.ts new file mode 100644 index 0000000..0188cb9 --- /dev/null +++ b/src/components/clue/index.ts @@ -0,0 +1,5 @@ +export { ClueHintModal } from "./ClueHintModal"; +export type { ClueHintModalProps, ClueHint } from "./ClueHintModal"; + +export { ClueBookModal } from "./ClueBookModal"; +export type { ClueBookModalProps } from "./ClueBookModal"; diff --git a/src/components/index.ts b/src/components/index.ts index c0e8e42..5276156 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -71,3 +71,11 @@ export type { // Game components export { BambooPuzzleGame } 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"; diff --git a/src/pages/sunzhongshan/reflection.tsx b/src/pages/sunzhongshan/reflection.tsx deleted file mode 100644 index 70107ab..0000000 --- a/src/pages/sunzhongshan/reflection.tsx +++ /dev/null @@ -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 ( -
- { - if (moduleName && starName) { - navigate(`/${moduleName}/${starName}/home`); - } - }} - /> -
-
孙中山故居-体悟模块(待完善)
-
-
- ); -}; - -export default SunzhongshanReflection; - diff --git a/src/pages/sunzhongshan/reflection/index.tsx b/src/pages/sunzhongshan/reflection/index.tsx new file mode 100644 index 0000000..10f6681 --- /dev/null +++ b/src/pages/sunzhongshan/reflection/index.tsx @@ -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(null); + + const handleSceneClick = (sceneNumber: number) => { + setSelectedScene(sceneNumber); + }; + + const handleStartExplore = () => { + if (selectedScene && moduleName && starName) { + navigate(`/${moduleName}/${starName}/reflection${selectedScene}`); + } + }; + + return ( +
+ {/* 关闭按钮 */} + { + if (moduleName && starName) { + navigate(`/${moduleName}/${starName}/home`); + } + }} + /> + + {/* 场景选择区域 */} +
+ {/* 场景一 */} +
handleSceneClick(1)} + > +
+ +
+
+ + {/* 场景二 */} +
handleSceneClick(2)} + > +
+ +
+
+ + {/* 场景三 */} +
handleSceneClick(3)} + > +
+ +
+
+
+ + {/* 开始探索按钮 */} +
+
+ +
+
+
+ ); +}; + +export default SunzhongshanReflection; diff --git a/src/pages/sunzhongshan/reflection/scene1.tsx b/src/pages/sunzhongshan/reflection/scene1.tsx new file mode 100644 index 0000000..767b479 --- /dev/null +++ b/src/pages/sunzhongshan/reflection/scene1.tsx @@ -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(null); + const clueBookButtonRef = useRef(null); + // 为每个线索创建 ref + const clueRefs = useRef>>( + new Map() + ); + + // 获取或创建线索的 ref + const getClueRef = ( + clueId: string + ): React.RefObject => { + if (!clueRefs.current.has(clueId)) { + clueRefs.current.set(clueId, React.createRef()); + } + 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, + toRef: clueBookButtonRef as React.RefObject, + 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, + toRef: clueBookButtonRef as React.RefObject, + 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 ( +
+ {/* 关闭按钮 */} + { + if (moduleName && starName) { + navigate(`/${moduleName}/${starName}/reflection`); + } + }} + /> + + {/* 线索点击区域 */} + {clueConfigs.map((clue) => { + const isCollected = isClueCollected(clue.id); + const clueRef = getClueRef(clue.id); + return ( +
+ {/* 线索元素(用于克隆和飞行) */} +
+
+ +
+ {clue.name} +
+
+
+ {/* 点击区域 */} +
handleClueClick(clue.id, clue.name)} + title={ + isCollected ? `${clue.name}(已收集)` : `点击收集${clue.name}` + } + /> +
+ ); + })} + + {/* 右下角-功能处 */} +
+ +
+ setShowClueBook(true)} + /> +
+
+ + {/* 线索提示窗 */} + setShowClueHint(false)} + sourceContainerRef={containerRef} + /> + + {/* 线索簿窗口 */} + ({ + id: c.id, + name: c.name, + collected: isClueCollected(c.id), + }))} + onClose={() => setShowClueBook(false)} + getClueImage={getClueImage} + getClueDetailImage={getClueDetailImage} + /> + + {/* 成功提示弹窗 */} + { + setShowSuccessAlert(false); + setSuccessAlertContent(""); + }} + onClose={() => { + setShowSuccessAlert(false); + setSuccessAlertContent(""); + }} + /> +
+ ); +}; + +export default SunzhongshanReflectionScene1; diff --git a/src/pages/sunzhongshan/reflection/scene2.tsx b/src/pages/sunzhongshan/reflection/scene2.tsx new file mode 100644 index 0000000..a8824f5 --- /dev/null +++ b/src/pages/sunzhongshan/reflection/scene2.tsx @@ -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(null); + const clueBookButtonRef = useRef(null); + // 为每个线索创建 ref + const clueRefs = useRef>>( + new Map() + ); + + // 获取或创建线索的 ref + const getClueRef = ( + clueId: string + ): React.RefObject => { + if (!clueRefs.current.has(clueId)) { + clueRefs.current.set(clueId, React.createRef()); + } + 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, + toRef: clueBookButtonRef as React.RefObject, + 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, + toRef: clueBookButtonRef as React.RefObject, + 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 ( +
+ {/* 关闭按钮 */} + { + if (moduleName && starName) { + navigate(`/${moduleName}/${starName}/reflection`); + } + }} + /> + + {/* 线索点击区域 */} + {clueConfigs.map((clue) => { + const isCollected = isClueCollected(clue.id); + const clueRef = getClueRef(clue.id); + return ( +
+ {/* 线索元素(用于克隆和飞行) */} +
+
+ +
+ {clue.name} +
+
+
+ {/* 点击区域 */} +
handleClueClick(clue.id, clue.name)} + title={ + isCollected ? `${clue.name}(已收集)` : `点击收集${clue.name}` + } + /> +
+ ); + })} + + {/* 右下角-功能处 */} +
+ +
+ setShowClueBook(true)} + /> +
+
+ + {/* 线索提示窗 */} + setShowClueHint(false)} + sourceContainerRef={containerRef} + /> + + {/* 线索簿窗口 */} + ({ + id: c.id, + name: c.name, + collected: isClueCollected(c.id), + }))} + onClose={() => setShowClueBook(false)} + getClueImage={getClueImage} + getClueDetailImage={getClueDetailImage} + /> + + {/* 成功提示弹窗 */} + { + setShowSuccessAlert(false); + setSuccessAlertContent(""); + }} + onClose={() => { + setShowSuccessAlert(false); + setSuccessAlertContent(""); + }} + /> +
+ ); +}; + +export default SunzhongshanReflectionScene2; diff --git a/src/pages/sunzhongshan/reflection/scene3.tsx b/src/pages/sunzhongshan/reflection/scene3.tsx new file mode 100644 index 0000000..e994a24 --- /dev/null +++ b/src/pages/sunzhongshan/reflection/scene3.tsx @@ -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(null); + const clueBookButtonRef = useRef(null); + // 为每个线索创建 ref + const clueRefs = useRef>>( + new Map() + ); + + // 获取或创建线索的 ref + const getClueRef = ( + clueId: string + ): React.RefObject => { + if (!clueRefs.current.has(clueId)) { + clueRefs.current.set(clueId, React.createRef()); + } + 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, + toRef: clueBookButtonRef as React.RefObject, + 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 ( +
+ {/* 关闭按钮 */} + { + if (moduleName && starName) { + navigate(`/${moduleName}/${starName}/reflection`); + } + }} + /> + + {/* 线索点击区域 */} + {clueConfigs.map((clue) => { + const isCollected = isClueCollected(clue.id); + const clueRef = getClueRef(clue.id); + return ( +
+ {/* 线索元素(用于克隆和飞行) */} +
+
+ +
+ {clue.name} +
+
+
+ {/* 点击区域 */} +
handleClueClick(clue.id, clue.name)} + title={ + isCollected ? `${clue.name}(已收集)` : `点击收集${clue.name}` + } + /> +
+ ); + })} + + {/* 右下角-功能处 */} +
+ setShowClueHint(true)} + /> +
+ setShowClueBook(true)} + /> +
+
+ + {/* 线索提示窗 */} + setShowClueHint(false)} + sourceContainerRef={containerRef} + /> + + {/* 线索簿窗口 */} + ({ + id: c.id, + name: c.name, + collected: isClueCollected(c.id), + }))} + onClose={() => setShowClueBook(false)} + getClueImage={getClueImage} + getClueDetailImage={getClueDetailImage} + /> + + {/* 成功提示弹窗 */} + { + setShowSuccessAlert(false); + setSuccessAlertContent(""); + }} + onClose={() => { + setShowSuccessAlert(false); + setSuccessAlertContent(""); + }} + /> +
+ ); +}; + +export default SunzhongshanReflectionScene3; diff --git a/src/router/modules/sunzhongshan.tsx b/src/router/modules/sunzhongshan.tsx index 5e8b45d..7d90760 100644 --- a/src/router/modules/sunzhongshan.tsx +++ b/src/router/modules/sunzhongshan.tsx @@ -3,11 +3,30 @@ import { RouteObject } from "react-router-dom"; import { ResidenceLayout } from "@/components/layout"; const HomePage = lazy(() => import("@/pages/sunzhongshan/home")); -const SunzhongshanPlace1 = lazy(() => import("@/pages/sunzhongshan/place/place1")); -const SunzhongshanPlace2 = lazy(() => 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")); +const SunzhongshanPlace1 = lazy( + () => import("@/pages/sunzhongshan/place/place1") +); +const SunzhongshanPlace2 = lazy( + () => 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( () => import("@/pages/sunzhongshan/internalization") ); @@ -65,6 +84,18 @@ export const SunzhongshanRoutes: RouteObject[] = [ path: "reflection", element: , }, + { + path: "reflection1", + element: , + }, + { + path: "reflection2", + element: , + }, + { + path: "reflection3", + element: , + }, { path: "internalization", element: , @@ -75,4 +106,3 @@ export const SunzhongshanRoutes: RouteObject[] = [ ], }, ]; - diff --git a/src/store/clueStore.ts b/src/store/clueStore.ts new file mode 100644 index 0000000..fb076b3 --- /dev/null +++ b/src/store/clueStore.ts @@ -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()( + 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 + } + ) +);